From cc8dd1070a33dfafeb2f56912e7e74410ac51c4c Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:10:20 +0800 Subject: [PATCH] feat: macOS architecture, cloud ASR/LLM providers, and 6-step iOS onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add macOS menu-bar dictation app with local ASR models (SenseVoice/Qwen3), global Option hotkey, and bottom overlay - Add cloud ASR/LLM providers (Anthropic, Volcengine, Bailian, and more) with provider logos, model listing, and connection checks - Add shared 7-day usage stats UI (UsageStatsCluster / SevenDayUsageChart) - Add iOS onboarding step 6 for polish LLM setup; hide custom-language-model diagnostic toggle behind DEBUG - Unify iOS onboarding tagline with the macOS brand line ("开口即文字。") - Rewrite README (Chinese-first, product-oriented) and refresh GitHub Pages --- .gitignore | 5 + CHANGELOG.md | 5 + .../anthropic.imageset/Contents.json | 16 + .../anthropic.imageset/anthropic.svg | 1 + .../ark.imageset/Contents.json | 16 + .../Assets.xcassets/ark.imageset/ark.svg | 1 + .../codingplanx.imageset/Contents.json | 16 + .../codingplanx.imageset/codingplanx.svg | 1 + .../cometapi.imageset/Contents.json | 16 + .../cometapi.imageset/cometapi.svg | 1 + .../gemini.imageset/Contents.json | 16 + .../gemini.imageset/gemini.svg | 1 + .../groq.imageset/Contents.json | 16 + .../Assets.xcassets/groq.imageset/groq.svg | 1 + .../minimax.imageset/Contents.json | 16 + .../minimax.imageset/minimax.svg | 1 + .../mistral.imageset/Contents.json | 16 + .../mistral.imageset/mistral.svg | 1 + .../openrouter.imageset/Contents.json | 16 + .../openrouter.imageset/openrouter.svg | 1 + .../siliconflow.imageset/Contents.json | 16 + .../siliconflow.imageset/siliconflow.svg | 1 + .../xai.imageset/Contents.json | 16 + .../Assets.xcassets/xai.imageset/xai.svg | 1 + OSGKeyboard/Services/FlowSessionManager.swift | 57 +- .../Services/OpenSourceLicenseCatalog.swift | 51 +- OSGKeyboard/Views/APISettingsCard.swift | 249 ++------ OSGKeyboard/Views/ASRSettingsCard.swift | 272 ++++----- .../Views/Components/HomeStatsCard.swift | 138 ----- .../Components/HomeUsageStatsSection.swift | 61 ++ .../Views/Components/MinimalTabBar.swift | 2 +- .../Components/PrivacyDisclosureViews.swift | 59 -- .../Components/WideLayoutComponents.swift | 161 +---- OSGKeyboard/Views/EnginePickerSection.swift | 29 +- OSGKeyboard/Views/HomeView.swift | 41 +- .../Views/LocalEngineSettingsRows.swift | 16 +- OSGKeyboard/Views/OnboardingView.swift | 101 ++-- .../Views/OpenSourceLicensesView.swift | 3 +- .../Views/PersonalDictionaryEntrySheet.swift | 3 +- .../PersonalDictionaryICloudSyncRow.swift | 99 --- .../Views/PersonalDictionaryView.swift | 3 +- OSGKeyboard/Views/ProviderPickerSection.swift | 77 ++- OSGKeyboard/Views/SettingsCardChrome.swift | 26 + OSGKeyboard/Views/SettingsICloudSyncRow.swift | 64 +- .../Views/SettingsProviderControls.swift | 316 ++++++++++ OSGKeyboard/Views/SettingsView.swift | 74 +-- OSGKeyboard/Views/TranslationPickerRow.swift | 5 +- OSGKeyboard/en.lproj/Localizable.strings | 58 +- OSGKeyboard/zh-Hans.lproj/Localizable.strings | 60 +- OSGKeyboardExt/KeyboardViewController.swift | 21 + .../Services/KeyboardFlowCoordinator.swift | 183 +++++- OSGKeyboardExt/Views/KeyboardRootView.swift | 23 +- .../Views/ToolbarActionButtons.swift | 17 + OSGKeyboardExt/en.lproj/Keyboard.strings | 13 +- OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings | 15 +- .../FlowLiveActivityWidget.swift | 2 +- OSGKeyboardMac/DashboardView.swift | 116 ++-- OSGKeyboardMac/MacComponents.swift | 527 +++++++++++++--- OSGKeyboardMac/MacDictationPipeline.swift | 3 +- OSGKeyboardMac/MacDictationViewModel.swift | 25 +- OSGKeyboardMac/MacDictionaryView.swift | 16 +- OSGKeyboardMac/MacHistoryView.swift | 16 +- OSGKeyboardMac/MacICloudSyncRows.swift | 138 ++--- .../MacLocalASRModelSettingsView.swift | 176 +++--- OSGKeyboardMac/MacOnboardingView.swift | 13 +- OSGKeyboardMac/MacQwen3ASREngine.swift | 95 --- OSGKeyboardMac/MacQwen3LocalASR.swift | 58 -- OSGKeyboardMac/MacRootView.swift | 10 +- OSGKeyboardMac/MacSettingsComponents.swift | 543 +++++++++++++++++ OSGKeyboardMac/MacSettingsView.swift | 566 +++++++++--------- OSGKeyboardMac/MacSherpaONNXRunner.swift | 20 + .../Configuration/ConfigurationStore.swift | 1 + .../LiveConfigurationStore.swift | 114 ++++ .../DesignSystem/SevenDayUsageChart.swift | 133 ++++ OSGKeyboardShared/DesignSystem/Theme.swift | 24 +- .../DesignSystem/UsageStatCard.swift | 108 ++++ .../DesignSystem/UsageStatsCluster.swift | 196 ++++++ .../DesignSystem/UsageSurfaceCard.swift | 36 ++ .../Models/AppGroupConfiguration.swift | 51 +- OSGKeyboardShared/Models/CloudASRModels.swift | 75 ++- .../Models/FlowHandoffPolicy.swift | 137 +++++ .../Models/HandednessPreference.swift | 4 +- OSGKeyboardShared/Models/LLMProvider.swift | 145 ++++- OSGKeyboardShared/Models/ProviderConfig.swift | 80 +-- OSGKeyboardShared/Models/ProviderLogo.swift | 16 +- .../Models/SyncedAppSettingsV2.swift | 15 + .../Models/SyncedUsageStatisticsV2.swift | 65 +- .../Models/VolcengineASRFields.swift | 73 +++ .../Services/AnthropicLLMClient.swift | 69 +++ .../Services/AppGroupStore.swift | 22 +- .../CloudASR/BailianRealtimeASRClient.swift | 434 ++++++++++++++ .../Services/CloudASR/CloudASRClients.swift | 167 ++++-- .../CloudASR/CloudASRConnectionCheck.swift | 19 + .../CloudASR/VolcengineCloudASRClient.swift | 400 +++++++++++++ .../Services/ICloudSync/AppCloudSync.swift | 7 +- .../ICloudSync/SettingsCloudSync.swift | 5 +- .../Services/KeyboardState.swift | 17 +- OSGKeyboardShared/Services/LLMClient.swift | 175 +++++- .../Services/PolishingService.swift | 12 +- .../Services/ProviderModelService.swift | 182 ++++++ .../Services/ProviderToolRunnerState.swift | 130 ++++ .../Services/UsageStatisticsStore.swift | 44 +- OSGKeyboardShared/en.lproj/Shared.strings | 69 ++- .../zh-Hans.lproj/Shared.strings | 69 ++- .../AppGroupConfigurationTests.swift | 9 +- OSGKeyboardTests/CloudASRTests.swift | 102 +++- .../ConfigurationStoreTests.swift | 4 +- OSGKeyboardTests/FlowHandoffPolicyTests.swift | 322 ++++++++++ OSGKeyboardTests/LLMClientTests.swift | 77 +++ OSGKeyboardTests/SettingsCloudSyncTests.swift | 3 + README.en.md | 103 ++++ README.md | 235 ++------ README.zh.md | 202 +------ docs/index.html | 290 +++++---- docs/local-asr-architecture.md | 14 +- project.yml | 20 +- 116 files changed, 6659 insertions(+), 2634 deletions(-) create mode 100644 OSGKeyboard/Assets.xcassets/anthropic.imageset/Contents.json create mode 100644 OSGKeyboard/Assets.xcassets/anthropic.imageset/anthropic.svg create mode 100644 OSGKeyboard/Assets.xcassets/ark.imageset/Contents.json create mode 100644 OSGKeyboard/Assets.xcassets/ark.imageset/ark.svg create mode 100644 OSGKeyboard/Assets.xcassets/codingplanx.imageset/Contents.json create mode 100644 OSGKeyboard/Assets.xcassets/codingplanx.imageset/codingplanx.svg create mode 100644 OSGKeyboard/Assets.xcassets/cometapi.imageset/Contents.json create mode 100644 OSGKeyboard/Assets.xcassets/cometapi.imageset/cometapi.svg create mode 100644 OSGKeyboard/Assets.xcassets/gemini.imageset/Contents.json create mode 100644 OSGKeyboard/Assets.xcassets/gemini.imageset/gemini.svg create mode 100644 OSGKeyboard/Assets.xcassets/groq.imageset/Contents.json create mode 100644 OSGKeyboard/Assets.xcassets/groq.imageset/groq.svg create mode 100644 OSGKeyboard/Assets.xcassets/minimax.imageset/Contents.json create mode 100644 OSGKeyboard/Assets.xcassets/minimax.imageset/minimax.svg create mode 100644 OSGKeyboard/Assets.xcassets/mistral.imageset/Contents.json create mode 100644 OSGKeyboard/Assets.xcassets/mistral.imageset/mistral.svg create mode 100644 OSGKeyboard/Assets.xcassets/openrouter.imageset/Contents.json create mode 100644 OSGKeyboard/Assets.xcassets/openrouter.imageset/openrouter.svg create mode 100644 OSGKeyboard/Assets.xcassets/siliconflow.imageset/Contents.json create mode 100644 OSGKeyboard/Assets.xcassets/siliconflow.imageset/siliconflow.svg create mode 100644 OSGKeyboard/Assets.xcassets/xai.imageset/Contents.json create mode 100644 OSGKeyboard/Assets.xcassets/xai.imageset/xai.svg delete mode 100644 OSGKeyboard/Views/Components/HomeStatsCard.swift create mode 100644 OSGKeyboard/Views/Components/HomeUsageStatsSection.swift delete mode 100644 OSGKeyboard/Views/PersonalDictionaryICloudSyncRow.swift create mode 100644 OSGKeyboard/Views/SettingsCardChrome.swift create mode 100644 OSGKeyboard/Views/SettingsProviderControls.swift delete mode 100644 OSGKeyboardMac/MacQwen3ASREngine.swift delete mode 100644 OSGKeyboardMac/MacQwen3LocalASR.swift create mode 100644 OSGKeyboardMac/MacSettingsComponents.swift create mode 100644 OSGKeyboardShared/Core/Configuration/LiveConfigurationStore.swift create mode 100644 OSGKeyboardShared/DesignSystem/SevenDayUsageChart.swift create mode 100644 OSGKeyboardShared/DesignSystem/UsageStatCard.swift create mode 100644 OSGKeyboardShared/DesignSystem/UsageStatsCluster.swift create mode 100644 OSGKeyboardShared/DesignSystem/UsageSurfaceCard.swift create mode 100644 OSGKeyboardShared/Models/FlowHandoffPolicy.swift create mode 100644 OSGKeyboardShared/Models/VolcengineASRFields.swift create mode 100644 OSGKeyboardShared/Services/AnthropicLLMClient.swift create mode 100644 OSGKeyboardShared/Services/CloudASR/BailianRealtimeASRClient.swift create mode 100644 OSGKeyboardShared/Services/CloudASR/CloudASRConnectionCheck.swift create mode 100644 OSGKeyboardShared/Services/CloudASR/VolcengineCloudASRClient.swift create mode 100644 OSGKeyboardShared/Services/ProviderModelService.swift create mode 100644 OSGKeyboardShared/Services/ProviderToolRunnerState.swift create mode 100644 OSGKeyboardTests/FlowHandoffPolicyTests.swift create mode 100644 README.en.md diff --git a/.gitignore b/.gitignore index e381900..a5f5db1 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,8 @@ __pycache__/ # Standalone experiment: 灵动岛录音机制验证 demo (throwaway) AudioIntentProbe/ + +# Local crash-log pulls and one-off diff/working notes (not for the repo) +.crash-pull/ +all-changes-vs-main.patch +CHANGES.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b1fbab6..025397b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.3] - 2026-07-11 + ### Added +- **Shared 7-day usage chart on iOS Home**: iPhone and iPad Home now show the same trailing-7-day dictation chart as macOS, via a shared `UsageStatsCluster`. / **iOS 首页共享近 7 天统计图**:iPhone / iPad 首页与 macOS 共用 `UsageStatsCluster`,展示近 7 天听写字数柱状图。 - **macOS dictation overlay**: a bottom-centered floating pill appears for any recording path (global hotkey, menu bar, or main window) — shows listening / transcribing state, a stronger live waveform, a one-line live transcript preview (partials when chunked ASR runs), front-app name, and a stop control, then briefly confirms success before fading out without stealing focus. / **macOS 听写浮层**:任意录音路径(全局热键、菜单栏或主窗口)都会在屏幕底部居中出现胶囊浮层——显示聆听 / 识别状态、更强的实时波形、单行转写预览(分块识别时显示 partial)、前台应用名与停止按钮,成功后短暂确认再淡出,且不抢前台焦点。 - **macOS Option-key picker**: Settings → Input lets you choose Left / Right / Either Option as the hold-to-talk key, so the shortcut can avoid conflicts with other apps. / **macOS Option 键选择**:设置 → 输入与快捷键可选择左 / 右 / 任一 Option 作为按住听写键,避免与其他应用冲突。 ### Changed +- **Shared home stats UI**: 7-day chart, stat tiles, and surface chrome live in `OSGKeyboardShared` (`UsageStatsCluster` / `UsageStatCard` / `SevenDayUsageChart`); Mac Dashboard and iOS Home (phone stacked / iPad split) both consume them. Localization keys moved from `mac.stat.*` to `stat.*`. / **共享首页统计 UI**:近 7 天图表、统计卡与表面壳迁入 `OSGKeyboardShared`(`UsageStatsCluster` / `UsageStatCard` / `SevenDayUsageChart`);Mac Dashboard 与 iOS 首页(手机上下叠 / iPad 左右分栏)共用。文案 key 由 `mac.stat.*` 改为 `stat.*`。 - **Removed temporary Flow DEBUG panels**: the on-screen App Group / session debug text boxes on Home and the keyboard extension are gone now that the orange-mic investigation is closed. / **移除临时 Flow DEBUG 面板**:橙色麦克风排查结束后,首页与键盘扩展上的 App Group / 会话调试文本框已去掉。 - **macOS local ASR catalog**: removed offline Paraformer; SenseVoice / Qwen3 0.6B / Qwen3 1.7B now show Fastest / Most balanced / Best quality badges (users still on Paraformer migrate to Qwen3 0.6B). / **macOS 本地 ASR 目录**:移除 offline Paraformer;SenseVoice / Qwen3 0.6B / Qwen3 1.7B 分别标注速度最快 / 最平衡 / 质量最好(仍选 Paraformer 的用户迁移到 Qwen3 0.6B)。 - **macOS visual system**: full-app redesign around the brand line “Speak it. It’s typed.” / 「开口即文字。」— grouped sidebar, restrained accent selection, asymmetric Home stats (chars as hero), unified page headers, quieter status footer, and clearer dark-mode card elevation. / **macOS 视觉体系**:围绕品牌句「开口即文字。」/ “Speak it. It’s typed.” 做全 App 设计升级——侧栏分组、克制的选中态、首页不对称统计(字数主卡)、统一页头、降权状态栏,以及更清晰的暗色卡片层次。 @@ -19,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **macOS page margins**: Home / History / Dictionary / Settings share `pageHorizontalInset` on titles and scroll *content*; ScrollViews / Forms stay full-bleed so the scrollbar sits on the window edge, while cards stay aligned with the page title. Settings keeps native grouped Form for control layout. / **macOS 页边距**:首页 / 历史 / 词库 / 设置在标题与滚动*内容*上共用 `pageHorizontalInset`;ScrollView / Form 通栏使滚动条贴窗口右缘,卡片仍与页标题对齐。设置保留原生分组 Form 以保证控件排版。 ### Fixed +- **Spurious "Voice is ready" overlay**: a healthy in-app Flow session no longer flashes the cold-start overlay after dictation. Keyboard mic presses wait when the session is still alive (including `preparingSession` / single-frame `hostNotReady` races); only a truly dead host opens `startflow`. The host silences overlay when already ready or busy. Proactive auto-launch is disabled. / **误弹「语音已就绪」**:App 内健康 Flow 会话在听写后不再闪冷启动浮层。键盘在会话仍存活时(含 `preparingSession` / 单帧 `hostNotReady` 竞态)只等待;仅宿主真正死亡才打开 `startflow`。主 App 在已就绪或忙碌时静默忽略浮层。已关闭被动自动拉起。 - **Cold-start overlay recursion crash**: dismissing the ready overlay while an utterance is already recording no longer recurses `refreshHostReady` → `reconcile` → `dismiss` on the main thread until stack overflow (`EXC_BAD_ACCESS`). Handoff flags are cleared before any refresh. / **冷启动浮层递归崩溃**:就绪浮层仍在时若已开始录音,不再在主线程上递归 `refreshHostReady` → `reconcile` → `dismiss` 直至栈溢出(`EXC_BAD_ACCESS`);交接标志会在任何 refresh 之前先清除。 - **macOS Qwen3 “language” garbage transcript**: Sherpa Qwen3 results that still include the model scaffold (`language Chinese…`) are now stripped to the spoken text; incomplete outputs that stop at the bare word `language` are treated as empty instead of being inserted. / **macOS Qwen3「language」乱码转写**:Sherpa Qwen3 结果若仍带模型脚手架(`language Chinese…`)会剥到真实口语文案;不完整输出停在单词 `language` 时按空结果处理,不再插入。 - **macOS local ASR silence garbage output**: dictating with no speech (silence) on a Sherpa-backed local model (SenseVoice/Qwen3/Paraformer) no longer inserts the raw JSON result line (`{"lang": "", "emotion": "", ...}`) as the transcript — it now correctly reports "no speech recognized". / **macOS 本地识别静音乱码**:使用 Sherpa 本地模型(SenseVoice/Qwen3/Paraformer)听写时若未检测到语音,不再把原始 JSON 结果行(`{"lang": "", "emotion": "", ...}`)当作转写文本插入,现在会正确提示「没有识别到语音」。 diff --git a/OSGKeyboard/Assets.xcassets/anthropic.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/anthropic.imageset/Contents.json new file mode 100644 index 0000000..9f10dd1 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/anthropic.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "anthropic.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/OSGKeyboard/Assets.xcassets/anthropic.imageset/anthropic.svg b/OSGKeyboard/Assets.xcassets/anthropic.imageset/anthropic.svg new file mode 100644 index 0000000..5b81844 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/anthropic.imageset/anthropic.svg @@ -0,0 +1 @@ +Anthropic \ No newline at end of file diff --git a/OSGKeyboard/Assets.xcassets/ark.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/ark.imageset/Contents.json new file mode 100644 index 0000000..6f122ce --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/ark.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "ark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/OSGKeyboard/Assets.xcassets/ark.imageset/ark.svg b/OSGKeyboard/Assets.xcassets/ark.imageset/ark.svg new file mode 100644 index 0000000..28556c3 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/ark.imageset/ark.svg @@ -0,0 +1 @@ +Volcengine \ No newline at end of file diff --git a/OSGKeyboard/Assets.xcassets/codingplanx.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/codingplanx.imageset/Contents.json new file mode 100644 index 0000000..5b51e01 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/codingplanx.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "codingplanx.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/OSGKeyboard/Assets.xcassets/codingplanx.imageset/codingplanx.svg b/OSGKeyboard/Assets.xcassets/codingplanx.imageset/codingplanx.svg new file mode 100644 index 0000000..d80b55c --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/codingplanx.imageset/codingplanx.svg @@ -0,0 +1 @@ + diff --git a/OSGKeyboard/Assets.xcassets/cometapi.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/cometapi.imageset/Contents.json new file mode 100644 index 0000000..39305f7 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/cometapi.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "cometapi.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/OSGKeyboard/Assets.xcassets/cometapi.imageset/cometapi.svg b/OSGKeyboard/Assets.xcassets/cometapi.imageset/cometapi.svg new file mode 100644 index 0000000..efe9317 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/cometapi.imageset/cometapi.svg @@ -0,0 +1 @@ +CometAPI \ No newline at end of file diff --git a/OSGKeyboard/Assets.xcassets/gemini.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/gemini.imageset/Contents.json new file mode 100644 index 0000000..ca57c6b --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/gemini.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "gemini.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/OSGKeyboard/Assets.xcassets/gemini.imageset/gemini.svg b/OSGKeyboard/Assets.xcassets/gemini.imageset/gemini.svg new file mode 100644 index 0000000..87736bb --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/gemini.imageset/gemini.svg @@ -0,0 +1 @@ +Gemini \ No newline at end of file diff --git a/OSGKeyboard/Assets.xcassets/groq.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/groq.imageset/Contents.json new file mode 100644 index 0000000..3874e19 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/groq.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "groq.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/OSGKeyboard/Assets.xcassets/groq.imageset/groq.svg b/OSGKeyboard/Assets.xcassets/groq.imageset/groq.svg new file mode 100644 index 0000000..7294646 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/groq.imageset/groq.svg @@ -0,0 +1 @@ +Groq \ No newline at end of file diff --git a/OSGKeyboard/Assets.xcassets/minimax.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/minimax.imageset/Contents.json new file mode 100644 index 0000000..ac7e6a2 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/minimax.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "minimax.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/OSGKeyboard/Assets.xcassets/minimax.imageset/minimax.svg b/OSGKeyboard/Assets.xcassets/minimax.imageset/minimax.svg new file mode 100644 index 0000000..1d32449 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/minimax.imageset/minimax.svg @@ -0,0 +1 @@ +Minimax \ No newline at end of file diff --git a/OSGKeyboard/Assets.xcassets/mistral.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/mistral.imageset/Contents.json new file mode 100644 index 0000000..f28b753 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/mistral.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "mistral.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/OSGKeyboard/Assets.xcassets/mistral.imageset/mistral.svg b/OSGKeyboard/Assets.xcassets/mistral.imageset/mistral.svg new file mode 100644 index 0000000..32c6cbd --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/mistral.imageset/mistral.svg @@ -0,0 +1 @@ +Mistral \ No newline at end of file diff --git a/OSGKeyboard/Assets.xcassets/openrouter.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/openrouter.imageset/Contents.json new file mode 100644 index 0000000..1ba973b --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/openrouter.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "openrouter.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/OSGKeyboard/Assets.xcassets/openrouter.imageset/openrouter.svg b/OSGKeyboard/Assets.xcassets/openrouter.imageset/openrouter.svg new file mode 100644 index 0000000..31fe130 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/openrouter.imageset/openrouter.svg @@ -0,0 +1 @@ +OpenRouter \ No newline at end of file diff --git a/OSGKeyboard/Assets.xcassets/siliconflow.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/siliconflow.imageset/Contents.json new file mode 100644 index 0000000..2386a84 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/siliconflow.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "siliconflow.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/OSGKeyboard/Assets.xcassets/siliconflow.imageset/siliconflow.svg b/OSGKeyboard/Assets.xcassets/siliconflow.imageset/siliconflow.svg new file mode 100644 index 0000000..f06093b --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/siliconflow.imageset/siliconflow.svg @@ -0,0 +1 @@ +SiliconCloud \ No newline at end of file diff --git a/OSGKeyboard/Assets.xcassets/xai.imageset/Contents.json b/OSGKeyboard/Assets.xcassets/xai.imageset/Contents.json new file mode 100644 index 0000000..a0cd740 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/xai.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "xai.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/OSGKeyboard/Assets.xcassets/xai.imageset/xai.svg b/OSGKeyboard/Assets.xcassets/xai.imageset/xai.svg new file mode 100644 index 0000000..536e713 --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/xai.imageset/xai.svg @@ -0,0 +1 @@ +Grok \ No newline at end of file diff --git a/OSGKeyboard/Services/FlowSessionManager.swift b/OSGKeyboard/Services/FlowSessionManager.swift index a19cba6..775321d 100644 --- a/OSGKeyboard/Services/FlowSessionManager.swift +++ b/OSGKeyboard/Services/FlowSessionManager.swift @@ -138,21 +138,43 @@ final class FlowSessionManager: ObservableObject { return } + reconcilePersistedFlowStateBeforeStart() + + // Healthy / busy sessions must not flash the cold-start overlay when a + // spurious `startflow` arrives (e.g. keyboard finalize race). + if coldStart, isActive { + extendSession(duration: duration) + refreshHostReady() + let decision = FlowHandoffPolicy.coldStartOverlayDecision( + sessionIsActive: true, + hostIsReady: FlowSessionBridge.isHostReady(), + isUtteranceBusy: isUtteranceRecording || isUtteranceProcessing + ) + switch decision { + case .silence: + traceState( + "startSession.coldStart.silenced", + extra: FlowSessionBridge.isHostReady() ? "reason=alreadyReady" : "reason=busy" + ) + return + case .present: + isColdStartHandoff = true + showColdStartPreparing() + Task { @MainActor [weak self] in + await self?.prepareExistingSessionForColdStartReturn() + } + return + } + } + if coldStart { isColdStartHandoff = true showColdStartPreparing() } - reconcilePersistedFlowStateBeforeStart() - if isActive { extendSession(duration: duration) refreshHostReady() - if coldStart { - Task { @MainActor [weak self] in - await self?.prepareExistingSessionForColdStartReturn() - } - } return } @@ -1354,8 +1376,9 @@ final class FlowSessionManager: ObservableObject { if Task.isCancelled { throw CancellationError() } - let polished = try await polisher.polish( - text, + let polished = try await Self.polishWithHostTimeout( + polisher: polisher, + text: text, mode: polishMode, providerIdOverride: pipelineStore.polishProviderIdOverride ) @@ -1542,6 +1565,22 @@ final class FlowSessionManager: ObservableObject { return FlowSessionKeys.cloudASRWaitTimeout } + /// Host-level polish cap — does not wait for a cancelled LLM task to unwind. + private static func polishWithHostTimeout( + polisher: PolishingService, + text: String, + mode: PolishingService.PolishMode, + providerIdOverride: String? + ) async throws -> String { + try await HardTimeout.run(seconds: FlowSessionKeys.maxPolishTimeout) { + try await polisher.polish( + text, + mode: mode, + providerIdOverride: providerIdOverride + ) + } + } + // MARK: - Level publishing (main thread only) private func startLevelPublishing() { diff --git a/OSGKeyboard/Services/OpenSourceLicenseCatalog.swift b/OSGKeyboard/Services/OpenSourceLicenseCatalog.swift index 5a9773f..a1e9558 100644 --- a/OSGKeyboard/Services/OpenSourceLicenseCatalog.swift +++ b/OSGKeyboard/Services/OpenSourceLicenseCatalog.swift @@ -4,12 +4,10 @@ // Single source of truth for third-party open-source components shipped // with OSGKeyboard. Consumed by Settings → About → Third-Party Licenses. // -// Keep this list aligned with `project.yml` package dependencies. +// Keep this list aligned with bundled resources and runtime downloads. // -// v0.2.0: dropped the `Qwen3Speech` SPM fork (Qwen3 CoreML ASR is gone) -// and the `aufklarer/Qwen3-ASR-CoreML` runtime artefact. The local -// engine now ships with iOS 26 `SpeechAnalyzer` + `DictationTranscriber` -// and has no on-device ML dependencies of our own. +// iOS targets remain zero-SPM. macOS local ASR downloads the sherpa-onnx +// runtime binary on demand; model weights are cached under Application Support. import Foundation @@ -26,19 +24,24 @@ enum OpenSourceLicenseCatalog { let licenseText: String } - /// Bundled libraries referenced by the app. v0.2.0 no longer pulls in - /// `soniqo/speech-swift` (we use iOS 26 `SpeechAnalyzer` instead) and - /// no longer downloads `Qwen3-ASR-CoreML` weights — both entries are - /// intentionally absent. + /// Bundled libraries and runtime components referenced by the app. static let entries: [Entry] = [ .init( id: "material-icons", name: "Google Material Icons", licenseName: "Apache-2.0", - purpose: "MaterialIcons-Regular.ttf bundled for Settings and navigation iconography.", + purpose: "MaterialIcons-Regular.ttf bundled with the iOS app for Settings and navigation iconography.", url: URL(string: "https://github.com/google/material-design-icons"), licenseText: apache2Text ), + .init( + id: "sherpa-onnx", + name: "sherpa-onnx", + licenseName: "Apache-2.0", + purpose: "macOS local ASR runtime (`sherpa-onnx-offline`) downloaded at install time and cached on device.", + url: URL(string: "https://github.com/k2-fsa/sherpa-onnx"), + licenseText: apache2Text + ), ] // MARK: - License bodies @@ -60,30 +63,4 @@ enum OpenSourceLicenseCatalog { implied. See the License for the specific language governing permissions and limitations under the License. """ - - static let mitText = """ - MIT License - - Copyright (c) 2023 Apple Inc. - - Permission is hereby granted, free of charge, to any person - obtaining a copy of this software and associated documentation - files (the "Software"), to deal in the Software without - restriction, including without limitation the rights to use, copy, - modify, merge, publish, distribute, sublicense, and/or sell copies - of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be - included in all copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT - HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER - DEALINGS IN THE SOFTWARE. - """ -} \ No newline at end of file +} diff --git a/OSGKeyboard/Views/APISettingsCard.swift b/OSGKeyboard/Views/APISettingsCard.swift index cd00e16..0ad329b 100644 --- a/OSGKeyboard/Views/APISettingsCard.swift +++ b/OSGKeyboard/Views/APISettingsCard.swift @@ -11,215 +11,86 @@ struct APISettingsCard: View { @Environment(\.themePalette) private var palette: ThemePalette @ObservedObject var config: ProviderConfig - @State private var showKey: Bool = false - @State private var testStatus: TestStatus = .idle - - private enum TestStatus: Equatable { - case idle - case running - case success - case failure(String) - } + /// 嵌入合并卡片时为 `false`,由外层统一绘制圆角背景。 + var showsSurface: Bool = true var body: some View { VStack(spacing: 0) { - field( + SettingsCredentialRow( + title: AppL10n.string("api.key"), + placeholder: "sk-…", + text: $config.apiKey, + isSecret: true, + isMonospaced: true + ) + rowDivider + SettingsCredentialRow( title: AppL10n.string("api.baseUrl"), - placeholder: "https://api.openai.com/v1", + placeholder: LLMProvider.provider(id: config.providerId).defaultBaseURL, text: $config.baseURL, - autocap: false + isMonospaced: true ) - Divider().background(palette.divider) - keyField - Divider().background(palette.divider) - field( + rowDivider + SettingsModelPickerRow( title: AppL10n.string("api.model"), - placeholder: "gpt-4o-mini", - text: $config.model, - autocap: false + placeholder: LLMProvider.provider(id: config.providerId).defaultModel, + model: $config.model, + fetchModels: fetchModels ) - if let url = LLMProvider.provider(id: config.providerId).apiKeyURL { - Divider().background(palette.divider) - // Use a Button + UIApplication.open instead of SwiftUI - // `Link`. SwiftUI `Link` can have hit-test quirks on some - // makes its tappable area eat gestures from the adjacent - // TextField, which manifests as "typing jumps to a website". - Button { - UIApplication.shared.open(url) - } label: { - HStack { - Text("api.getKey") - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) - Spacer() - Image(systemName: "arrow.up.right.square") - .foregroundStyle(palette.textSecondary) - } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - Divider().background(palette.divider) - testConnectionRow + .id(config.providerId) + rowDivider + thinkingRow + rowDivider + SettingsProviderToolsRow(validate: validateConnection) } - .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.large, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) + .modifier(SettingsSurfaceCardModifier(enabled: showsSurface)) } - // MARK: - Key - - private var keyField: some View { - VStack(alignment: .leading, spacing: 6) { - HStack { - Text("api.key") - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - Spacer() - Button(action: { showKey.toggle() }) { - Image(systemName: showKey ? "eye.slash.fill" : "eye.fill") - .foregroundStyle(palette.textSecondary) - } - .buttonStyle(.plain) - .accessibilityLabel(Text(showKey - ? AppL10n.string("api.key.hide") - : AppL10n.string("api.key.show"))) - } - Group { - if showKey { - TextField("sk-…", text: $config.apiKey) - } else { - SecureField("sk-…", text: $config.apiKey) - } - } - .keyboardType(.asciiCapable) - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) - } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center) + private var rowDivider: some View { + Divider().background(palette.divider) } - // MARK: - Generic field - - @ViewBuilder - private func field( - title: String, - placeholder: String, - text: Binding, - autocap: Bool - ) -> some View { - VStack(alignment: .leading, spacing: 6) { - Text(title) - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - TextField(placeholder, text: text) - .keyboardType(.asciiCapable) - .autocorrectionDisabled(true) - .textInputAutocapitalization(autocap ? .sentences : .never) - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) - .submitLabel(.done) - .onSubmit { /* no-op: prevent the keyboard from "submitting" - and dismissing the sheet unexpectedly */ } - } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center) - } - - // MARK: - Test connection - - private var testConnectionRow: some View { - VStack(alignment: .leading, spacing: 6) { - HStack { - Text("api.connection") + /// Two-line thinking toggle — opt-in for slower, higher-quality polish. + private var thinkingRow: some View { + HStack(alignment: .center, spacing: Spacing.sm) { + VStack(alignment: .leading, spacing: Spacing.xxs) { + Text(AppL10n.string("settings.provider.thinking")) .font(TypeStyle.body) .foregroundStyle(palette.textPrimary) - Spacer() - Button(action: runTest) { - Group { - if testStatus == .running { - ProgressView().controlSize(.mini) - } else { - Text(testButtonLabel) - .font(TypeStyle.body) - .foregroundStyle(testTint) - } - } - } - .buttonStyle(.plain) - .disabled(testStatus == .running) - } - if let detail = testDetail { - Text(detail) + .fixedSize(horizontal: false, vertical: true) + Text(AppL10n.string("settings.provider.thinkingSubtitle")) .font(TypeStyle.caption2) - .foregroundStyle(testTint) - .lineLimit(3) + .foregroundStyle(palette.textTertiary) + .fixedSize(horizontal: false, vertical: true) } + Spacer(minLength: 0) + Toggle("", isOn: $config.llmThinkingEnabled) + .labelsHidden() + .tint(palette.accent) + .accessibilityLabel(AppL10n.string("settings.provider.thinking")) } - .padding(.horizontal, Spacing.md) - .padding(.vertical, Spacing.xs) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .center) + .settingsListRow() } - private var testButtonLabel: String { - switch testStatus { - case .idle: return AppL10n.string("api.test.idle") - case .running: return AppL10n.string("api.test.running") - case .success: return AppL10n.string("api.test.success") - case .failure: return AppL10n.string("api.test.failure") - } - } - - private var testTint: Color { - switch testStatus { - case .idle, .running: return palette.accent - case .success: return palette.accent - case .failure: return palette.danger - } - } - - private var testDetail: String? { - switch testStatus { - case .idle, .running, .success: return nil - case .failure(let message): return message - } - } - - private func runTest() { - testStatus = .running - let store = AppGroupStore() - let client = OpenAICompatibleClient( - baseURL: store.baseURL, - apiKey: store.apiKey, - model: store.model + private func validateConnection() async throws { + // Use on-screen config — not a fresh AppGroupStore — so a just-typed + // key is visible even if Keychain write is still settling. + let client = LLMClientFactory.make( + providerId: config.providerId, + baseURL: config.baseURL, + apiKey: config.apiKey, + model: config.model, + thinkingEnabled: config.llmThinkingEnabled + ) + _ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.") + } + + private func fetchModels() async throws -> [String] { + try await ProviderModelService.listLLMModels( + providerId: config.providerId, + baseURL: config.baseURL, + apiKey: config.apiKey, + currentModel: config.model ) - Task { - do { - _ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.") - testStatus = .success - } catch LLMError.noAPIKey { - testStatus = .failure(AppL10n.string("api.test.missing")) - } catch let error as LLMError { - switch error { - case .http(let status): - testStatus = .failure(AppL10n.format("api.test.http", status)) - case .rateLimited: - testStatus = .failure(AppL10n.string("api.test.rateLimited")) - case .transport(let msg): - testStatus = .failure(AppL10n.format("api.test.transportWith", msg)) - default: - testStatus = .failure(error.errorDescription ?? "\(error)") - } - } catch { - testStatus = .failure((error as? LocalizedError)?.errorDescription ?? "\(error)") - } - } } } diff --git a/OSGKeyboard/Views/ASRSettingsCard.swift b/OSGKeyboard/Views/ASRSettingsCard.swift index 7989c2e..f98cd22 100644 --- a/OSGKeyboard/Views/ASRSettingsCard.swift +++ b/OSGKeyboard/Views/ASRSettingsCard.swift @@ -10,195 +10,131 @@ struct ASRSettingsCard: View { @Environment(\.themePalette) private var palette: ThemePalette @ObservedObject var config: ProviderConfig - @State private var showKey: Bool = false - @State private var testStatus: TestStatus = .idle - - private enum TestStatus: Equatable { - case idle - case running - case success - case failure(String) - } + /// 嵌入合并卡片时为 `false`,由外层统一绘制圆角背景。 + var showsSurface: Bool = true var body: some View { VStack(spacing: 0) { - if CloudASRModelCatalog.strategy(for: config.asrProviderId) == .prompt { - field( - title: AppL10n.string("api.baseUrl"), - placeholder: "https://api.openai.com/v1", - text: $config.asrBaseURL, - autocap: false - ) - Divider().background(palette.divider) + if config.asrProviderId == "volcengine" { + volcengineRows + } else { + genericRows } - keyField - Divider().background(palette.divider) - field( - title: AppL10n.string("settings.asr.model"), - placeholder: CloudASRModelCatalog.defaultModel(for: config.asrProviderId), - text: $config.asrModel, - autocap: false - ) - if let url = LLMProvider.provider(id: config.asrProviderId).apiKeyURL { - Divider().background(palette.divider) - Button { - UIApplication.shared.open(url) - } label: { - HStack { - Text("api.getKey") - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) - Spacer() - Image(systemName: "arrow.up.right.square") - .foregroundStyle(palette.textSecondary) - } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - Divider().background(palette.divider) - testConnectionRow + rowDivider + SettingsProviderToolsRow(validate: validateConnection) } - .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.large, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) - } - - private var keyField: some View { - VStack(alignment: .leading, spacing: 6) { - HStack { - Text("api.key") - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - Spacer() - Button(action: { showKey.toggle() }) { - Image(systemName: showKey ? "eye.slash.fill" : "eye.fill") - .foregroundStyle(palette.textSecondary) - } - .buttonStyle(.plain) - } - Group { - if showKey { - TextField("sk-…", text: $config.asrApiKey) - } else { - SecureField("sk-…", text: $config.asrApiKey) - } - } - .keyboardType(.asciiCapable) - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) - } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center) + .modifier(SettingsSurfaceCardModifier(enabled: showsSurface)) } @ViewBuilder - private func field( - title: String, - placeholder: String, - text: Binding, - autocap: Bool - ) -> some View { - VStack(alignment: .leading, spacing: 6) { - Text(title) - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - TextField(placeholder, text: text) - .keyboardType(.asciiCapable) - .autocorrectionDisabled(true) - .textInputAutocapitalization(autocap ? .sentences : .never) - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) + private var genericRows: some View { + if CloudASRModelCatalog.showsASREndpointField(for: config.asrProviderId) { + SettingsCredentialRow( + title: AppL10n.string("api.baseUrl"), + placeholder: LLMProvider.provider(id: config.asrProviderId).defaultBaseURL, + text: $config.asrBaseURL, + isMonospaced: true + ) + rowDivider } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center) + + SettingsCredentialRow( + title: AppL10n.string("api.key"), + placeholder: "sk-…", + text: $config.asrApiKey, + isSecret: true, + isMonospaced: true + ) + rowDivider + SettingsModelPickerRow( + title: AppL10n.string("settings.asr.model"), + placeholder: CloudASRModelCatalog.defaultModel(for: config.asrProviderId), + model: $config.asrModel, + fetchModels: fetchModels + ) + .id(config.asrProviderId) } - private var testConnectionRow: some View { - VStack(alignment: .leading, spacing: 6) { - HStack { - Text("settings.asr.testConnection") - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) - Spacer() - Button(action: runTest) { - Group { - if testStatus == .running { - ProgressView().controlSize(.mini) - } else { - Text(testButtonLabel) - .font(TypeStyle.body) - .foregroundStyle(testTint) - } - } - } - .buttonStyle(.plain) - .disabled(testStatus == .running) - } - if let detail = testDetail { - Text(detail) - .font(TypeStyle.caption2) - .foregroundStyle(testTint) - .lineLimit(3) + private var volcengineRows: some View { + Group { + SettingsCredentialRow( + title: AppL10n.string("settings.asr.volcengine.appId"), + placeholder: "APP ID", + text: Binding( + get: { volcengineFields.appID }, + set: { updateVolcengine(appID: $0) } + ), + isSecret: true, + isMonospaced: true + ) + rowDivider + SettingsCredentialRow( + title: AppL10n.string("settings.asr.volcengine.accessToken"), + placeholder: "Access Token", + text: Binding( + get: { volcengineFields.accessToken }, + set: { updateVolcengine(accessToken: $0) } + ), + isSecret: true, + isMonospaced: true + ) + rowDivider + SettingsCredentialRow( + title: AppL10n.string("settings.asr.volcengine.resourceId"), + placeholder: CloudASRModelCatalog.defaultModel(for: "volcengine"), + text: Binding( + get: { volcengineFields.resourceID }, + set: { updateVolcengine(resourceID: $0) } + ), + isMonospaced: true, + defaultValue: CloudASRModelCatalog.defaultModel(for: "volcengine") + ) + rowDivider + SettingsProviderRow(title: AppL10n.string("settings.provider.note")) { + Text("settings.asr.volcengine.note") + .font(TypeStyle.caption) + .foregroundStyle(palette.textTertiary) + .fixedSize(horizontal: false, vertical: true) } } - .padding(.horizontal, Spacing.md) - .padding(.vertical, Spacing.xs) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .center) } - private var testButtonLabel: String { - switch testStatus { - case .idle: return AppL10n.string("api.test.idle") - case .running: return AppL10n.string("api.test.running") - case .success: return AppL10n.string("api.test.success") - case .failure: return AppL10n.string("api.test.failure") - } + private var rowDivider: some View { + Divider().background(palette.divider) } - private var testTint: Color { - switch testStatus { - case .idle, .running: return palette.accent - case .success: return palette.accent - case .failure: return palette.danger - } + private var volcengineFields: VolcengineASRFields { + VolcengineASRFields.parse( + apiKey: config.asrApiKey, + resourceFallback: config.asrModel.isEmpty + ? CloudASRModelCatalog.defaultModel(for: "volcengine") + : config.asrModel + ) } - private var testDetail: String? { - switch testStatus { - case .idle, .running, .success: return nil - case .failure(let message): return message + private func updateVolcengine(appID: String? = nil, accessToken: String? = nil, resourceID: String? = nil) { + var fields = volcengineFields + if let appID { fields.appID = appID } + if let accessToken { fields.accessToken = accessToken } + if let resourceID { + fields.resourceID = resourceID + config.asrModel = resourceID } + config.asrApiKey = fields.encodedAPIKey } - private func runTest() { - testStatus = .running - let store = AppGroupStore() - let client = CloudASRClientFactory.make(store: store) - Task { - do { - try await client.prepare(dictionary: store.personalDictionary) - let samples = [Float](repeating: 0.01, count: 16_000) - _ = try await client.transcribe( - samples: samples, - sampleRate: 16_000, - locale: Locale(identifier: store.localeId == "auto" ? "zh-CN" : store.localeId), - dictionary: store.personalDictionary - ) - testStatus = .success - } catch CloudASRError.noAPIKey { - testStatus = .failure(AppL10n.string("api.test.missing")) - } catch let error as CloudASRError { - testStatus = .failure(error.localizedDescription ?? "\(error)") - } catch { - testStatus = .failure((error as? LocalizedError)?.errorDescription ?? "\(error)") - } - } + private func validateConnection() async throws { + let persisted = AppGroupStore() + let live = LiveConfigurationStore(config: config, fallback: persisted) + try await CloudASRConnectionCheck.validate(store: live) + } + + private func fetchModels() async throws -> [String] { + try await ProviderModelService.listASRModels( + providerId: config.asrProviderId, + baseURL: config.asrBaseURL, + apiKey: config.asrApiKey, + currentModel: config.asrModel + ) } } diff --git a/OSGKeyboard/Views/Components/HomeStatsCard.swift b/OSGKeyboard/Views/Components/HomeStatsCard.swift deleted file mode 100644 index 9b42a7d..0000000 --- a/OSGKeyboard/Views/Components/HomeStatsCard.swift +++ /dev/null @@ -1,138 +0,0 @@ -// HomeStatsCard.swift -// OSGKeyboard · Main App -// -// Home screen summary: dictation time, dictation characters, -// translation characters, and personal-dictionary entry count. - -import SwiftUI -import OSGKeyboardShared - -struct HomeStatsCard: View { - @Environment(\.themePalette) private var palette: ThemePalette - @Environment(\.colorScheme) private var colorScheme - - @ObservedObject private var stats = UsageStatisticsStore.shared - @ObservedObject private var config = ProviderConfig.shared - - @State private var dictionaryCount = 0 - - private enum Layout { - static let fixedHeight: CGFloat = 166 - static let valueFontSize: CGFloat = 24 - static let iconSize: CGFloat = 18 - } - - var body: some View { - VStack(spacing: 0) { - HStack(spacing: 0) { - statCell( - systemImage: "waveform", - value: UsageStatisticsStore.formatDuration( - stats.dictationDurationSeconds, - language: config.uiLanguage - ), - label: "home.stats.dictationDuration" - ) - divider - statCell( - systemImage: "text.alignleft", - value: UsageStatisticsStore.formatCount( - stats.dictationCharacterCount, - language: config.uiLanguage - ), - label: "home.stats.dictationCharacters" - ) - } - horizontalDivider - HStack(spacing: 0) { - statCell( - systemImage: "character.bubble", - value: UsageStatisticsStore.formatCount( - stats.translationCharacterCount, - language: config.uiLanguage - ), - label: "home.stats.translationCharacters" - ) - divider - statCell( - systemImage: "square.stack.3d.down.right.fill", - value: UsageStatisticsStore.formatCount( - dictionaryCount, - language: config.uiLanguage - ), - label: "home.stats.dictionaryEntries" - ) - } - } - .frame(height: Layout.fixedHeight) - .background(cardBackground) - .clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) - .onAppear(perform: refreshDictionaryCount) - .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in - refreshDictionaryCount() - } - .onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in - refreshDictionaryCount() - } - .onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in - stats.reloadFromDisk() - } - } - - private func statCell(systemImage: String, value: String, label: LocalizedStringKey) -> some View { - HStack(alignment: .top, spacing: Spacing.xs) { - VStack(alignment: .leading, spacing: Spacing.xs) { - Text(value) - .font(.system(size: Layout.valueFontSize, weight: .semibold, design: .rounded)) - .foregroundStyle(palette.textPrimary) - .lineLimit(1) - .minimumScaleFactor(0.75) - Text(label) - .font(TypeStyle.caption2) - .foregroundStyle(palette.textSecondary) - .lineLimit(1) - .minimumScaleFactor(0.85) - } - Spacer(minLength: Spacing.xs) - Image(systemName: systemImage) - .font(.system(size: Layout.iconSize, weight: .semibold)) - .foregroundStyle(palette.accent) - .padding(.top, 2) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(Spacing.md) - } - - private var divider: some View { - Rectangle() - .fill(palette.divider) - .frame(width: 0.5) - } - - private var horizontalDivider: some View { - Rectangle() - .fill(palette.divider) - .frame(height: 0.5) - } - - private func refreshDictionaryCount() { - dictionaryCount = AppGroupStore().personalDictionary.entries.count - } - - private var cardBackground: Color { - colorScheme == .dark ? palette.surface : .white - } -} - -#if DEBUG -#Preview { - ThemedRoot { - HomeStatsCard() - .padding() - } -} -#endif diff --git a/OSGKeyboard/Views/Components/HomeUsageStatsSection.swift b/OSGKeyboard/Views/Components/HomeUsageStatsSection.swift new file mode 100644 index 0000000..9feeebb --- /dev/null +++ b/OSGKeyboard/Views/Components/HomeUsageStatsSection.swift @@ -0,0 +1,61 @@ +// HomeUsageStatsSection.swift +// OSGKeyboard · Main App +// +// Observes usage + dictionary counts and feeds the shared +// `UsageStatsCluster` (phone stacked / iPad split). + +import SwiftUI +import OSGKeyboardShared + +struct HomeUsageStatsSection: View { + let layout: UsageStatsCluster.Layout + var compact: Bool = false + + @ObservedObject private var stats = UsageStatisticsStore.shared + @ObservedObject private var config = ProviderConfig.shared + + @State private var dictionaryCount = 0 + + var body: some View { + UsageStatsCluster( + layout: layout, + language: config.uiLanguage, + points: stats.last7Days, + dictationCharacterCount: stats.dictationCharacterCount, + dictationDurationSeconds: stats.dictationDurationSeconds, + translationCharacterCount: stats.translationCharacterCount, + dictionaryTermCount: dictionaryCount, + compact: compact + ) + .onAppear(perform: refreshDictionaryCount) + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in + refreshDictionaryCount() + } + .onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in + refreshDictionaryCount() + } + .onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in + stats.reloadFromDisk() + } + } + + private func refreshDictionaryCount() { + dictionaryCount = AppGroupStore().personalDictionary.entries.count + } +} + +#if DEBUG +#Preview("Phone stacked") { + ThemedRoot { + HomeUsageStatsSection(layout: .stacked) + .padding() + } +} + +#Preview("Wide split") { + ThemedRoot { + HomeUsageStatsSection(layout: .split) + .padding() + } +} +#endif diff --git a/OSGKeyboard/Views/Components/MinimalTabBar.swift b/OSGKeyboard/Views/Components/MinimalTabBar.swift index f3abcb9..b717ef6 100644 --- a/OSGKeyboard/Views/Components/MinimalTabBar.swift +++ b/OSGKeyboard/Views/Components/MinimalTabBar.swift @@ -23,7 +23,7 @@ enum AppTab: Int, CaseIterable { } } - /// Matches `HomeStatsCard` dictionary stat cell (filled variant). + /// Filled SF Symbol override for the dictionary tab. var sfSymbol: String? { switch self { case .dictionary: return "square.stack.3d.down.right.fill" diff --git a/OSGKeyboard/Views/Components/PrivacyDisclosureViews.swift b/OSGKeyboard/Views/Components/PrivacyDisclosureViews.swift index eccf0c9..4ebc785 100644 --- a/OSGKeyboard/Views/Components/PrivacyDisclosureViews.swift +++ b/OSGKeyboard/Views/Components/PrivacyDisclosureViews.swift @@ -32,62 +32,3 @@ struct PrivacyInfoCard: View { ) } } - -/// Footnote when Cloud polish is active — text goes to the user's configured API. -struct CloudPolishDisclosureBanner: View { - @Environment(\.themePalette) private var palette: ThemePalette - - var body: some View { - HStack(alignment: .top, spacing: Spacing.sm) { - Image(systemName: "info.circle") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(palette.accent) - Text("settings.privacy.cloud.body") - .font(TypeStyle.caption2) - .foregroundStyle(palette.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } - .padding(Spacing.md) - .frame(maxWidth: .infinity, alignment: .leading) - .background(palette.accentMuted.opacity(0.35), in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) - } -} - -/// First-time confirmation before enabling Cloud polish (user-configured third-party API). -struct CloudSharingAcknowledgmentModifier: ViewModifier { - @ObservedObject var config: ProviderConfig - @Binding var isPresented: Bool - let onConfirm: () -> Void - let onCancel: () -> Void - - func body(content: Content) -> some View { - content - .alert("settings.privacy.cloud.alert.title", isPresented: $isPresented) { - Button("common.continue") { - config.hasAcknowledgedCloudSharing = true - onConfirm() - } - Button("common.cancel", role: .cancel) { - onCancel() - } - } message: { - Text("settings.privacy.cloud.alert.message") - } - } -} - -extension View { - func cloudSharingAcknowledgment( - config: ProviderConfig, - isPresented: Binding, - onConfirm: @escaping () -> Void, - onCancel: @escaping () -> Void - ) -> some View { - modifier(CloudSharingAcknowledgmentModifier( - config: config, - isPresented: isPresented, - onConfirm: onConfirm, - onCancel: onCancel - )) - } -} diff --git a/OSGKeyboard/Views/Components/WideLayoutComponents.swift b/OSGKeyboard/Views/Components/WideLayoutComponents.swift index 261ebbd..c9318dd 100644 --- a/OSGKeyboard/Views/Components/WideLayoutComponents.swift +++ b/OSGKeyboard/Views/Components/WideLayoutComponents.swift @@ -4,6 +4,8 @@ // Reusable layout pieces for iPad / regular-width surfaces. Styled with the // shared design tokens so the wide Home dashboard can mirror the macOS shell // without pulling in AppKit-only types from OSGKeyboardMac. +// +// Home stats (chart + metric tiles) live in Shared as `UsageStatsCluster`. import SwiftUI import OSGKeyboardShared @@ -21,7 +23,7 @@ enum WideLayoutMetrics { // MARK: - Card container -/// Elevated surface used for stat tiles and the dictation canvas. +/// Elevated surface used for the dictation canvas on wide Home. struct WideCard: View { @Environment(\.themePalette) private var palette var padding: CGFloat = Spacing.md @@ -39,160 +41,3 @@ struct WideCard: View { ) } } - -// MARK: - Stat tile - -struct WideStatCard: View { - @Environment(\.themePalette) private var palette - let title: String - let value: String - let caption: String - var systemImage: String? - var accent: Bool = false - /// Hero metric: wide horizontal layout for the primary word count. - var prominent: Bool = false - - var body: some View { - WideCard(padding: Spacing.md) { - if prominent { - prominentBody - } else { - compactBody - } - } - } - - private var compactBody: some View { - VStack(alignment: .leading, spacing: Spacing.xs) { - HStack { - Text(title.uppercased()) - .font(TypeStyle.caption2) - .tracking(0.6) - .foregroundStyle(palette.textTertiary) - Spacer() - if let systemImage { - Image(systemName: systemImage) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(accent ? palette.accent : palette.textTertiary) - .symbolRenderingMode(.hierarchical) - } - } - Text(value) - .font(TypeStyle.title2) - .foregroundStyle(accent ? palette.accent : palette.textPrimary) - .lineLimit(1) - .minimumScaleFactor(0.7) - .contentTransition(.numericText()) - .animation(Motion.soft, value: value) - Text(caption) - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - private var prominentBody: some View { - HStack(spacing: Spacing.md) { - if let systemImage { - ZStack { - Circle() - .fill(palette.accentMuted) - .frame(width: 44, height: 44) - Image(systemName: systemImage) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(palette.accent) - .symbolRenderingMode(.hierarchical) - } - } - VStack(alignment: .leading, spacing: 2) { - Text(title.uppercased()) - .font(TypeStyle.caption2) - .tracking(0.6) - .foregroundStyle(palette.textTertiary) - Text(caption) - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - } - Spacer(minLength: Spacing.md) - Text(value) - .font(.system(size: 34, weight: .bold)) - .foregroundStyle(accent ? palette.accent : palette.textPrimary) - .lineLimit(1) - .minimumScaleFactor(0.6) - .contentTransition(.numericText()) - .animation(Motion.soft, value: value) - } - } -} - -// MARK: - Home stats cluster - -/// Dashboard-style stat cluster for the wide Home layout. -struct WideHomeStatsCluster: View { - @ObservedObject private var stats = UsageStatisticsStore.shared - @ObservedObject private var config = ProviderConfig.shared - - @State private var dictionaryCount = 0 - - private var language: AppUILanguage { config.uiLanguage } - - var body: some View { - VStack(spacing: Spacing.md) { - WideStatCard( - title: AppL10n.string("home.stats.dictationCharacters", language: language), - value: UsageStatisticsStore.formatCount( - stats.dictationCharacterCount, - language: language - ), - caption: AppL10n.string("home.wide.stat.transcribed", language: language), - systemImage: "text.alignleft", - accent: true, - prominent: true - ) - - HStack(spacing: Spacing.md) { - WideStatCard( - title: AppL10n.string("home.stats.dictationDuration", language: language), - value: UsageStatisticsStore.formatDuration( - stats.dictationDurationSeconds, - language: language - ), - caption: AppL10n.string("home.wide.stat.cumulativeDuration", language: language), - systemImage: "waveform" - ) - WideStatCard( - title: AppL10n.string("home.stats.translationCharacters", language: language), - value: UsageStatisticsStore.formatCount( - stats.translationCharacterCount, - language: language - ), - caption: AppL10n.string("home.wide.stat.cumulativeTranslation", language: language), - systemImage: "character.bubble" - ) - WideStatCard( - title: AppL10n.string("home.stats.dictionaryEntries", language: language), - value: UsageStatisticsStore.formatCount( - dictionaryCount, - language: language - ), - caption: AppL10n.string("home.wide.stat.customTerms", language: language), - systemImage: "character.book.closed" - ) - } - } - .onAppear(perform: refreshDictionaryCount) - .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in - refreshDictionaryCount() - } - .onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in - refreshDictionaryCount() - } - .onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in - stats.reloadFromDisk() - } - } - - private func refreshDictionaryCount() { - dictionaryCount = AppGroupStore().personalDictionary.entries.count - } -} diff --git a/OSGKeyboard/Views/EnginePickerSection.swift b/OSGKeyboard/Views/EnginePickerSection.swift index 81fbf9d..edc3cee 100644 --- a/OSGKeyboard/Views/EnginePickerSection.swift +++ b/OSGKeyboard/Views/EnginePickerSection.swift @@ -11,8 +11,6 @@ struct EnginePickerSection: View { @Environment(\.themePalette) private var palette: ThemePalette @ObservedObject var config: ProviderConfig - @State private var showCloudAcknowledgment = false - @State private var pendingEngineSelection: String? var body: some View { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { @@ -37,17 +35,7 @@ struct EnginePickerSection: View { RoundedRectangle(cornerRadius: Radius.large, style: .continuous) .stroke(palette.divider, lineWidth: 0.5) ) - - if config.engineMode == "cloud" { - CloudPolishDisclosureBanner() - } } - .cloudSharingAcknowledgment( - config: config, - isPresented: $showCloudAcknowledgment, - onConfirm: { applyPendingEngineSelection() }, - onCancel: { pendingEngineSelection = nil } - ) } private var localSubtitle: String { @@ -64,11 +52,6 @@ struct EnginePickerSection: View { let isSelected = config.engineMode == id return Button { guard config.engineMode != id else { return } - if id == "cloud", !config.hasAcknowledgedCloudSharing { - pendingEngineSelection = id - showCloudAcknowledgment = true - return - } selectEngine(id) } label: { HStack(spacing: Spacing.sm) { @@ -92,8 +75,7 @@ struct EnginePickerSection: View { .foregroundStyle(palette.accent) } } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.doubleLineMinHeight) + .settingsListRow() .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -104,19 +86,10 @@ struct EnginePickerSection: View { config.engineMode = id if id == "cloud" { config.modeId = "polish" - if config.providerId == "deepseek" { - config.apply(preset: LLMProvider.provider(id: "openai")) - } } } } - private func applyPendingEngineSelection() { - guard let id = pendingEngineSelection else { return } - pendingEngineSelection = nil - selectEngine(id) - } - @ViewBuilder private func engineMark(assetName: String?, systemIcon: String?, isSelected: Bool) -> some View { ZStack { diff --git a/OSGKeyboard/Views/HomeView.swift b/OSGKeyboard/Views/HomeView.swift index ff0d257..2f54229 100644 --- a/OSGKeyboard/Views/HomeView.swift +++ b/OSGKeyboard/Views/HomeView.swift @@ -85,6 +85,16 @@ struct HomeView: View { private var phoneBody: some View { GeometryReader { geo in let gradientHeight = geo.size.height * 0.30 + geo.safeAreaInsets.top + // 小屏(如 iPhone SE)压缩顶部留白,把空间让给自适应的输入框, + // 避免固定块之和超出视口、底部状态行被 tab 栏遮挡。 + let isCompact = geo.size.height < 700 + // logo 上下留白对称,避免视觉上偏下。 + let logoTopPadding = isCompact ? Spacing.lg : Spacing.xxl + let logoBottomPadding = isCompact ? Spacing.lg : Spacing.xxl + let extrasBottomPadding = isCompact ? Spacing.sm : Spacing.lg + let statusTopPadding = isCompact ? Spacing.sm : Spacing.xl + // 输入框最小高度:小屏可压得更矮,让底部状态行始终留在 tab 栏之上。 + let previewMinHeight: CGFloat = isCompact ? 72 : 160 ZStack(alignment: .top) { sessionHeaderGradient(height: gradientHeight) @@ -92,23 +102,25 @@ struct HomeView: View { .allowsHitTesting(false) VStack(spacing: 0) { - logoHeader - .padding(.top, Spacing.xxxl) - .padding(.bottom, Spacing.xxl) + logoHeader(compact: isCompact) + .padding(.top, logoTopPadding) + .padding(.bottom, logoBottomPadding) if showsFlowSessionExtras { flowSessionExtras .padding(.horizontal, Spacing.lg) - .padding(.bottom, Spacing.lg) + .padding(.bottom, extrasBottomPadding) } - HomeStatsCard() + HomeUsageStatsSection(layout: .stacked, compact: isCompact) .padding(.horizontal, Spacing.lg) .padding(.bottom, Spacing.md) - previewField + // 唯一的弹性区块:吸收全部剩余空间(大屏铺满、小屏优先让位)。 + previewField(minHeight: previewMinHeight) .padding(.horizontal, Spacing.lg) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .layoutPriority(-1) HStack(spacing: Spacing.sm) { engineStatusLine @@ -116,7 +128,7 @@ struct HomeView: View { } .frame(maxWidth: .infinity, alignment: .center) .padding(.horizontal, Spacing.lg) - .padding(.top, Spacing.xl) + .padding(.top, statusTopPadding) .padding(.bottom, Spacing.sm) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) @@ -138,7 +150,7 @@ struct HomeView: View { VStack(alignment: .leading, spacing: Spacing.lg) { wideHeroHeader - WideHomeStatsCluster() + HomeUsageStatsSection(layout: .split) if showsFlowSessionExtras { flowSessionExtras @@ -235,12 +247,15 @@ struct HomeView: View { // MARK: - Header - private var logoHeader: some View { - VStack(spacing: Spacing.xxl) { + // logo 尺寸保持 144:41 比例;小屏进一步缩小,给下方内容让空间。 + private func logoHeader(compact: Bool) -> some View { + let logoWidth: CGFloat = compact ? 104 : 124 + let logoHeight = logoWidth * (41.0 / 144.0) + return VStack(spacing: Spacing.xxl) { Image("osglogo") .resizable() .scaledToFit() - .frame(width: 144, height: 41) + .frame(width: logoWidth, height: logoHeight) .accessibilityHidden(true) } .frame(maxWidth: .infinity) @@ -444,9 +459,9 @@ struct HomeView: View { // MARK: - Preview field - private var previewField: some View { + private func previewField(minHeight: CGFloat) -> some View { previewFieldContent - .frame(maxWidth: .infinity, minHeight: 180, maxHeight: .infinity, alignment: .topLeading) + .frame(maxWidth: .infinity, minHeight: minHeight, maxHeight: .infinity, alignment: .topLeading) .padding(Spacing.md) .background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .overlay( diff --git a/OSGKeyboard/Views/LocalEngineSettingsRows.swift b/OSGKeyboard/Views/LocalEngineSettingsRows.swift index c76d2ca..6251d08 100644 --- a/OSGKeyboard/Views/LocalEngineSettingsRows.swift +++ b/OSGKeyboard/Views/LocalEngineSettingsRows.swift @@ -25,8 +25,10 @@ struct LocalModelsGroup: View { speechRow Divider().background(palette.divider) polishRow +#if DEBUG Divider().background(palette.divider) customLanguageModelDiagnosticRow +#endif } .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .overlay( @@ -45,8 +47,7 @@ struct LocalModelsGroup: View { Spacer(minLength: Spacing.xs) engineBadge("settings.localModels.speechEngine") } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .settingsListRow() } // MARK: Polish row @@ -61,12 +62,12 @@ struct LocalModelsGroup: View { Spacer(minLength: Spacing.xs) engineBadge("settings.localModels.polishEngine") } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .settingsListRow() } - // MARK: Custom language model diagnostic row + // MARK: Custom language model diagnostic row (DEBUG builds only) +#if DEBUG private var customLanguageModelDiagnosticRow: some View { Toggle(isOn: $config.localASRCustomLanguageModelEnabled) { VStack(alignment: .leading, spacing: Spacing.xxs) { @@ -79,10 +80,9 @@ struct LocalModelsGroup: View { } } .tint(palette.accent) - .padding(.horizontal, Spacing.md) - .padding(.vertical, Spacing.sm) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .settingsListRow() } +#endif // MARK: Helpers diff --git a/OSGKeyboard/Views/OnboardingView.swift b/OSGKeyboard/Views/OnboardingView.swift index 985e571..b49cb34 100644 --- a/OSGKeyboard/Views/OnboardingView.swift +++ b/OSGKeyboard/Views/OnboardingView.swift @@ -1,12 +1,13 @@ // OnboardingView.swift // OSGKeyboard · Main App // -// Five-step onboarding: +// Six-step onboarding: // 1) Welcome // 2) Microphone permission // 3) Speech recognition permission // 4) Enable keyboard + Allow Full Access -// 5) Engine / API setup +// 5) Engine / ASR setup +// 6) Polish LLM setup import SwiftUI import OSGKeyboardShared @@ -18,8 +19,9 @@ private enum OnboardingPage: Int, CaseIterable { case speech case keyboard case api + case polish - static let count = 5 + static let count = 6 } struct OnboardingView: View { @@ -61,6 +63,8 @@ struct OnboardingView: View { EnableKeyboardPage() case .api: APISetupPage(config: config) + case .polish: + PolishSetupPage(config: config) } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -234,14 +238,14 @@ struct OnboardingView: View { } } - private var isLastPage: Bool { config.onboardingPage == OnboardingPage.api.rawValue } + private var isLastPage: Bool { config.onboardingPage == OnboardingPage.polish.rawValue } private var canAdvance: Bool { switch currentPage { - case .welcome, .keyboard: - return !isLastPage || onboardingCompleteReady - case .api: + case .welcome, .keyboard, .api: return !isLastPage || onboardingCompleteReady + case .polish: + return onboardingCompleteReady case .microphone: return micStatus != .undetermined case .speech: @@ -250,9 +254,8 @@ struct OnboardingView: View { } private var onboardingCompleteReady: Bool { - // v0.2.0: the local engine uses iOS `SpeechAnalyzer`, which is - // always available — no model download gate. The cloud engine - // still requires base URL + API key + model. + // Local engine: built-in polish satisfies `isPolishConfigured`. + // Cloud engine: ASR (step 5) + polish LLM (step 6) must both be ready. if config.isLocalEngine { return true } return config.isConfigured } @@ -364,7 +367,6 @@ private struct OnboardingTitleBlock: View { // MARK: - Welcome private struct WelcomePage: View { - @Environment(\.themePalette) private var palette: ThemePalette @State private var logoAppeared = false var body: some View { @@ -386,26 +388,13 @@ private struct WelcomePage: View { } OnboardingTitleBlock( - title: "onboarding.welcome.tagline", - subtitle: "onboarding.welcome.subtitle", - secondarySubtitle: "onboarding.welcome.subtitle2" + title: "onboarding.welcome.tagline" ) .opacity(logoAppeared ? 1 : 0) .offset(y: logoAppeared ? 0 : 10) .padding(.top, OnboardingLayoutMetrics.heroTextGap) .animation(.spring(response: 0.8, dampingFraction: 0.85).delay(0.12), value: logoAppeared) - if let url = LegalLinks.privacyPolicyURL { - Link(destination: url) { - Text("legal.privacyPolicy") - .font(TypeStyle.caption) - .foregroundStyle(palette.accent) - } - .padding(.top, Spacing.xxxl) - .opacity(logoAppeared ? 1 : 0) - .animation(.easeOut(duration: 0.45).delay(0.28), value: logoAppeared) - } - Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -708,7 +697,7 @@ private struct EnableKeyboardPage: View { } } -// MARK: - API setup +// MARK: - Engine / ASR setup private struct APISetupPage: View { @Environment(\.themePalette) private var palette: ThemePalette @@ -728,16 +717,60 @@ private struct APISetupPage: View { .padding(.horizontal, Spacing.lg) if config.engineMode == "cloud" { - ProviderPickerSection(config: config) - .padding(.horizontal, Spacing.lg) - APISettingsCard(config: config) - .padding(.horizontal, Spacing.lg) + VStack(spacing: 0) { + ProviderPickerSection(config: config, role: .asr, showsSurface: false) + Divider().background(palette.divider) + ASRSettingsCard(config: config, showsSurface: false) + } + .modifier(SettingsSurfaceCardModifier(enabled: true)) + .padding(.horizontal, Spacing.lg) } else { - // Local engine: built-in ASR + built-in polish (no API card). - LocalModelsGroup(config: config) - .padding(.horizontal, Spacing.lg) + Text("onboarding.api.localModels.hint") + .font(TypeStyle.body) + .foregroundStyle(palette.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, Spacing.xl) } - + } + .padding(.bottom, Spacing.xxxl) + } + } +} + +// MARK: - Polish LLM setup + +private struct PolishSetupPage: View { + @Environment(\.themePalette) private var palette: ThemePalette + + @ObservedObject var config: ProviderConfig + + var body: some View { + ScrollView { + VStack(spacing: Spacing.lg) { + OnboardingHeroIcon(systemName: "wand.and.stars", circleSize: 72, iconSize: 30) + .padding(.top, Spacing.xl) + + OnboardingTitleBlock( + title: "onboarding.polish.title", + subtitle: "onboarding.polish.subtitle" + ) + .padding(.horizontal, Spacing.lg) + + if config.isLocalEngine { + Text("onboarding.polish.localHint") + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, Spacing.xl) + } + + VStack(spacing: 0) { + ProviderPickerSection(config: config, role: .polish, showsSurface: false) + Divider().background(palette.divider) + APISettingsCard(config: config, showsSurface: false) + } + .modifier(SettingsSurfaceCardModifier(enabled: true)) + .padding(.horizontal, Spacing.lg) } .padding(.bottom, Spacing.xxxl) } diff --git a/OSGKeyboard/Views/OpenSourceLicensesView.swift b/OSGKeyboard/Views/OpenSourceLicensesView.swift index 21e7f6c..61c99ac 100644 --- a/OSGKeyboard/Views/OpenSourceLicensesView.swift +++ b/OSGKeyboard/Views/OpenSourceLicensesView.swift @@ -64,8 +64,7 @@ struct OpenSourceLicensesView: View { .font(.system(size: 11, weight: .semibold)) .foregroundStyle(palette.textTertiary) } - .padding(.horizontal, Spacing.md) - .padding(.vertical, 10) + .settingsListRow() .contentShape(Rectangle()) } } diff --git a/OSGKeyboard/Views/PersonalDictionaryEntrySheet.swift b/OSGKeyboard/Views/PersonalDictionaryEntrySheet.swift index 94d0ef9..ec68026 100644 --- a/OSGKeyboard/Views/PersonalDictionaryEntrySheet.swift +++ b/OSGKeyboard/Views/PersonalDictionaryEntrySheet.swift @@ -30,8 +30,7 @@ struct PersonalDictionaryEntrySheet: View { .focused($termFocused) .textInputAutocapitalization(.never) .autocorrectionDisabled() - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .settingsListRow() .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: Radius.large, style: .continuous) diff --git a/OSGKeyboard/Views/PersonalDictionaryICloudSyncRow.swift b/OSGKeyboard/Views/PersonalDictionaryICloudSyncRow.swift deleted file mode 100644 index f6652a5..0000000 --- a/OSGKeyboard/Views/PersonalDictionaryICloudSyncRow.swift +++ /dev/null @@ -1,99 +0,0 @@ -// PersonalDictionaryICloudSyncRow.swift -// OSGKeyboard · Main App -// -// Settings-row toggle for mirroring the personal dictionary through -// iCloud Key-Value Store. Lives in Settings → 词库与润色. - -import SwiftUI -import OSGKeyboardShared - -@MainActor -struct PersonalDictionaryICloudSyncRow: View { - @Environment(\.themePalette) private var palette: ThemePalette - - @State private var isEnabled: Bool = AppGroupStore().personalDictionaryICloudSyncEnabled - @State private var syncErrorMessage: String? - @State private var isApplyingToggle = false - - private let store = AppGroupStore() - - var body: some View { - VStack(alignment: .leading, spacing: Spacing.xxs) { - Toggle(isOn: toggleBinding) { - Text("settings.personalDictionary.iCloudSync.settingsTitle") - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) - } - .tint(palette.accent) - .disabled(isApplyingToggle) - - if let syncErrorMessage { - Text(syncErrorMessage) - .font(TypeStyle.caption2) - .foregroundStyle(.red) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, Spacing.xxs) - } - } - .padding(.horizontal, Spacing.md) - .padding(.vertical, Spacing.sm) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .leading) - .onAppear { reloadFromStore() } - .onReceive( - NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud) - ) { _ in - reloadFromStore() - } - } - - private var toggleBinding: Binding { - Binding( - get: { isEnabled }, - set: { newValue in - guard newValue != isEnabled else { return } - if newValue { - enableSync() - } else { - disableSync() - } - } - ) - } - - private func reloadFromStore() { - isEnabled = store.personalDictionaryICloudSyncEnabled - } - - private func enableSync() { - isApplyingToggle = true - syncErrorMessage = nil - Task { - do { - try await PersonalDictionaryCloudSync.shared.enableSync() - reloadFromStore() - } catch let error as PersonalDictionaryCloudSyncError { - isEnabled = false - syncErrorMessage = localizedSyncError(error) - } catch { - isEnabled = false - syncErrorMessage = error.localizedDescription - } - isApplyingToggle = false - } - } - - private func disableSync() { - PersonalDictionaryCloudSync.shared.disableSync() - isEnabled = false - syncErrorMessage = nil - } - - private func localizedSyncError(_ error: PersonalDictionaryCloudSyncError) -> String { - switch error { - case .payloadTooLarge: - return AppL10n.string("settings.personalDictionary.iCloudSync.error.tooLarge") - case .encodeFailed, .decodeFailed: - return AppL10n.string("settings.personalDictionary.iCloudSync.error.generic") - } - } -} diff --git a/OSGKeyboard/Views/PersonalDictionaryView.swift b/OSGKeyboard/Views/PersonalDictionaryView.swift index 0c4aaf5..8f0ce75 100644 --- a/OSGKeyboard/Views/PersonalDictionaryView.swift +++ b/OSGKeyboard/Views/PersonalDictionaryView.swift @@ -170,8 +170,7 @@ struct PersonalDictionaryView: View { .font(.caption.weight(.semibold)) .foregroundStyle(palette.textTertiary) } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .settingsListRow() .contentShape(Rectangle()) } .buttonStyle(.plain) diff --git a/OSGKeyboard/Views/ProviderPickerSection.swift b/OSGKeyboard/Views/ProviderPickerSection.swift index 93c35c2..cd81944 100644 --- a/OSGKeyboard/Views/ProviderPickerSection.swift +++ b/OSGKeyboard/Views/ProviderPickerSection.swift @@ -9,33 +9,47 @@ struct ProviderPickerSection: View { @ObservedObject var config: ProviderConfig var role: CloudProviderRole = .polish + /// 嵌入合并卡片时为 `false`,由外层统一绘制圆角背景。 + var showsSurface: Bool = true private var selectedProviderId: String { role == .asr ? config.asrProviderId : config.providerId } - var body: some View { - let visiblePresets = role == .asr + private var visiblePresets: [LLMProvider] { + role == .asr ? LLMProvider.asrSelectablePresets : LLMProvider.userSelectablePresets - VStack(spacing: 0) { - ForEach(Array(visiblePresets.enumerated()), id: \.element.id) { index, provider in - Button { - select(provider) - } label: { - row(provider, selected: provider.id == selectedProviderId) - } - .buttonStyle(.plain) - if index < visiblePresets.count - 1 { - Divider().background(palette.divider) + } + + private var selectedProvider: LLMProvider { + visiblePresets.first(where: { $0.id == selectedProviderId }) + ?? LLMProvider.provider(id: selectedProviderId) + } + + var body: some View { + // 只有右侧芯片是 Menu 的 label;标题留在行外,避免菜单弹出时 + // 把整行 label 一起隐藏,导致左侧「供应商」文字消失。 + SettingsProviderRow(title: AppL10n.string("settings.provider.supplier")) { + Menu { + ForEach(visiblePresets, id: \.id) { provider in + Button { + select(provider) + } label: { + let name = ProviderDisplayName.name(for: provider.id) + if provider.id == selectedProviderId { + Label(name, systemImage: "checkmark") + } else { + Text(name) + } + } } + } label: { + providerChip } + .buttonStyle(.plain) } - .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.large, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) + .modifier(SettingsSurfaceCardModifier(enabled: showsSurface)) } private func select(_ provider: LLMProvider) { @@ -49,48 +63,45 @@ struct ProviderPickerSection: View { } } - @ViewBuilder - private func row(_ provider: LLMProvider, selected: Bool) -> some View { + /// 收起状态展示:当前所选供应商 logo + 名称 + 展开箭头。 + private var providerChip: some View { HStack(spacing: Spacing.sm) { - providerMark(provider, selected: selected) + providerMark(selectedProvider) - Text(ProviderDisplayName.name(for: provider.id)) + Text(ProviderDisplayName.name(for: selectedProvider.id)) .font(TypeStyle.body) .foregroundStyle(palette.textPrimary) - if provider.supportsPersonalDictionaryCloudASR { + if selectedProvider.supportsPersonalDictionaryCloudASR { personalDictionaryBadge } Spacer(minLength: Spacing.xs) - if selected { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 17, weight: .medium)) - .foregroundStyle(palette.accent) - } + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(palette.textTertiary) } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } @ViewBuilder - private func providerMark(_ provider: LLMProvider, selected: Bool) -> some View { + private func providerMark(_ provider: LLMProvider) -> some View { ZStack { Circle() - .fill(selected ? palette.accentMuted : palette.surfaceElevated) + .fill(palette.accentMuted) .frame(width: 32, height: 32) if let asset = ProviderLogo.assetName(for: provider.id) { Image(asset) .resizable() .scaledToFit() .frame(width: 18, height: 18) - .foregroundStyle(selected ? palette.accent : palette.textPrimary) + .foregroundStyle(palette.accent) } else { Text(String(provider.name.prefix(1))) .font(TypeStyle.caption) - .foregroundStyle(selected ? palette.accent : palette.textSecondary) + .foregroundStyle(palette.accent) } } } diff --git a/OSGKeyboard/Views/SettingsCardChrome.swift b/OSGKeyboard/Views/SettingsCardChrome.swift new file mode 100644 index 0000000..17d4fb2 --- /dev/null +++ b/OSGKeyboard/Views/SettingsCardChrome.swift @@ -0,0 +1,26 @@ +// SettingsCardChrome.swift +// OSGKeyboard · Main App +// +// Shared rounded surface chrome for settings list cards. + +import SwiftUI +import OSGKeyboardShared + +struct SettingsSurfaceCardModifier: ViewModifier { + @Environment(\.themePalette) private var palette: ThemePalette + + let enabled: Bool + + func body(content: Content) -> some View { + if enabled { + content + .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) + } else { + content + } + } +} diff --git a/OSGKeyboard/Views/SettingsICloudSyncRow.swift b/OSGKeyboard/Views/SettingsICloudSyncRow.swift index 7a89baa..0ff408e 100644 --- a/OSGKeyboard/Views/SettingsICloudSyncRow.swift +++ b/OSGKeyboard/Views/SettingsICloudSyncRow.swift @@ -14,6 +14,9 @@ struct SettingsICloudSyncRow: View { @State private var syncErrorMessage: String? @State private var isApplyingToggle = false @State private var isSyncingNow = false + /// Transient success flag: shows a brief "已同步" confirmation so a fast + /// sync gives visible feedback instead of a spinner that flashes once. + @State private var showSyncedConfirmation = false private let store = AppGroupStore() @@ -38,13 +41,21 @@ struct SettingsICloudSyncRow: View { syncNow() } label: { HStack(spacing: Spacing.xs) { + Text(syncButtonTitleKey) + .font(TypeStyle.caption) + Spacer(minLength: 0) if isSyncingNow { ProgressView() - .controlSize(.small) + .controlSize(.mini) + } else if showSyncedConfirmation { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .semibold)) } - Text("settings.appSettings.iCloudSync.syncNow") - .font(TypeStyle.caption) } + // Fill the row so the whole strip is tappable, not just + // the caption glyphs (previously easy to miss). + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) } .buttonStyle(.plain) .foregroundStyle(palette.accent) @@ -60,15 +71,18 @@ struct SettingsICloudSyncRow: View { .padding(.top, Spacing.xxs) } } - .padding(.horizontal, Spacing.md) - .padding(.vertical, Spacing.sm) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .leading) + .settingsListRow(alignment: .leading) .onAppear { reloadFromStore() } .onReceive( NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud) ) { _ in reloadFromStore() } + .onReceive( + NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud) + ) { _ in + reloadFromStore() + } } private var toggleBinding: Binding { @@ -85,6 +99,16 @@ struct SettingsICloudSyncRow: View { ) } + private var syncButtonTitleKey: LocalizedStringKey { + if isSyncingNow { + return "settings.appSettings.iCloudSync.syncing" + } + if showSyncedConfirmation { + return "settings.appSettings.iCloudSync.synced" + } + return "settings.appSettings.iCloudSync.syncNow" + } + private func reloadFromStore() { isEnabled = store.settingsICloudSyncEnabled } @@ -95,6 +119,15 @@ struct SettingsICloudSyncRow: View { Task { do { try await CloudSyncContext.shared.settingsSyncService.enableSync() + do { + try await CloudSyncContext.shared.dictionarySyncService.enableSync() + } catch let error as PersonalDictionaryCloudSyncError { + CloudSyncContext.shared.settingsSyncService.disableSync() + isEnabled = false + syncErrorMessage = localizedDictionarySyncError(error) + isApplyingToggle = false + return + } reloadFromStore() } catch let error as SettingsCloudSyncError { isEnabled = false @@ -114,15 +147,23 @@ struct SettingsICloudSyncRow: View { } private func syncNow() { + guard !isSyncingNow else { return } isSyncingNow = true + showSyncedConfirmation = false syncErrorMessage = nil Task { do { try await CloudSyncContext.shared.syncNow() + isSyncingNow = false + withAnimation { showSyncedConfirmation = true } + // Auto-dismiss the confirmation so the label returns to + // its default "立即同步" state. + try? await Task.sleep(nanoseconds: 1_800_000_000) + withAnimation { showSyncedConfirmation = false } } catch { + isSyncingNow = false syncErrorMessage = AppL10n.string("settings.appSettings.iCloudSync.error.generic") } - isSyncingNow = false } } @@ -132,4 +173,13 @@ struct SettingsICloudSyncRow: View { return AppL10n.string("settings.appSettings.iCloudSync.error.generic") } } + + private func localizedDictionarySyncError(_ error: PersonalDictionaryCloudSyncError) -> String { + switch error { + case .payloadTooLarge: + return AppL10n.string("settings.personalDictionary.iCloudSync.error.tooLarge") + case .encodeFailed, .decodeFailed: + return AppL10n.string("settings.personalDictionary.iCloudSync.error.generic") + } + } } diff --git a/OSGKeyboard/Views/SettingsProviderControls.swift b/OSGKeyboard/Views/SettingsProviderControls.swift new file mode 100644 index 0000000..d0f7fbc --- /dev/null +++ b/OSGKeyboard/Views/SettingsProviderControls.swift @@ -0,0 +1,316 @@ +// SettingsProviderControls.swift +// OSGKeyboard · Main App +// +// OpenLess-style setting rows for provider credentials and tools. Compact iOS +// stacks label above control; regular-width iPad keeps label/control in one row. + +import SwiftUI +import OSGKeyboardShared + +struct SettingsProviderRow: View { + @Environment(\.themePalette) private var palette: ThemePalette + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + + let title: String + @ViewBuilder var content: () -> Content + + var body: some View { + if horizontalSizeClass == .compact { + VStack(alignment: .leading, spacing: 8) { + label + content() + .frame(maxWidth: .infinity, alignment: .leading) + } + .settingsListRow() + } else { + HStack(alignment: .center, spacing: Spacing.lg) { + label + .frame(width: 150, alignment: .leading) + content() + .frame(maxWidth: .infinity, alignment: .leading) + } + .settingsListRow() + } + } + + private var label: some View { + Text(title) + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + } +} + +struct SettingsCredentialRow: View { + @Environment(\.themePalette) private var palette: ThemePalette + + let title: String + let placeholder: String + @Binding var text: String + var isSecret: Bool = false + var isMonospaced: Bool = false + var defaultValue: String? + var trailing: AnyView? + + @State private var revealed = false + + var body: some View { + SettingsProviderRow(title: title) { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: Spacing.xs) { + input + if let defaultValue, text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + iconButton(systemName: "checkmark", label: "settings.provider.fillDefault") { + text = defaultValue + } + } + if let trailing { + trailing + } + if isSecret { + iconButton(systemName: revealed ? "eye.slash" : "eye", label: revealed ? "api.key.hide" : "api.key.show") { + revealed.toggle() + } + } + } + } + } + } + + @ViewBuilder + private var input: some View { + Group { + if isSecret && !revealed { + SecureField(placeholder, text: $text) + } else { + TextField(placeholder, text: $text) + } + } + .keyboardType(.asciiCapable) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .font(isMonospaced ? TypeStyle.mono : TypeStyle.caption) + .foregroundStyle(palette.textPrimary) + .padding(.horizontal, Spacing.sm) + .frame(minHeight: 38) + .background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) + } + + private func iconButton(systemName: String, label: LocalizedStringKey, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: systemName) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(palette.textSecondary) + .frame(width: 38, height: 38) + .background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(label) + } +} + +// MARK: - Editable model field + fetch icon + +/// Model id: type freely, or fetch then pick from the trailing dropdown. +struct SettingsModelPickerRow: View { + @Environment(\.themePalette) private var palette: ThemePalette + + let title: String + let placeholder: String + @Binding var model: String + let fetchModels: () async throws -> [String] + + @State private var models: [String] = [] + @State private var isRunning = false + @State private var message: String? + @State private var failed = false + + private let controlHeight: CGFloat = 38 + + var body: some View { + SettingsProviderRow(title: title) { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: Spacing.xs) { + comboField + refreshButton + } + + if let message { + Text(message) + .font(TypeStyle.caption2) + .foregroundStyle(failed ? palette.danger : palette.accent) + .lineLimit(3) + } + } + } + } + + /// Editable model id + trailing menu chevron in one well (same chrome as + /// Mac `MacPickerFieldBox`). Chevron is overlaid so it stays inside the border. + private var comboField: some View { + let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + let chevronWidth: CGFloat = 28 + return ZStack(alignment: .trailing) { + TextField(placeholder, text: $model) + .keyboardType(.asciiCapable) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .font(TypeStyle.mono) + .foregroundStyle(palette.textPrimary) + .padding(.leading, Spacing.sm) + .padding(.trailing, chevronWidth + Spacing.sm) + .frame(maxWidth: .infinity, minHeight: controlHeight, alignment: .leading) + .background(palette.surfaceElevated, in: shape) + .overlay(shape.stroke(palette.divider, lineWidth: 0.5)) + + Menu { + if models.isEmpty { + Button(AppL10n.string("settings.provider.modelsEmptyHint")) {} + .disabled(true) + } else { + ForEach(models, id: \.self) { modelId in + Button { + model = modelId + message = AppL10n.format("settings.provider.modelSelected", modelId) + failed = false + } label: { + if modelId == model { + Label(modelId, systemImage: "checkmark") + } else { + Text(modelId) + } + } + } + } + } label: { + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(palette.textTertiary) + .frame(width: chevronWidth, height: controlHeight) + .contentShape(Rectangle()) + } + .padding(.trailing, Spacing.sm) + .accessibilityLabel(AppL10n.string("settings.provider.selectModel")) + } + } + + private var refreshButton: some View { + Button { + Task { await runFetchModels() } + } label: { + Group { + if isRunning { + ProgressView() + .controlSize(.mini) + } else { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(palette.textSecondary) + } + } + .frame(width: controlHeight, height: controlHeight) + .background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + .disabled(isRunning) + .accessibilityLabel(AppL10n.string("settings.provider.fetchModels")) + } + + @MainActor + private func runFetchModels() async { + isRunning = true + failed = false + defer { isRunning = false } + + let outcome = await ProviderToolRunner.runFetchModels( + runningMessage: AppL10n.string("settings.provider.loadingModels"), + loadedMessage: { AppL10n.format("settings.provider.modelsLoaded", $0) }, + emptyMessage: SharedL10n.string("providerTools.error.empty"), + currentModel: model, + fetchModels: fetchModels + ) + models = outcome.state.models + message = outcome.state.message + failed = outcome.state.failed + if let selected = outcome.selectedModel { + model = selected + } + } +} + +// MARK: - Connection validate only + +struct SettingsProviderToolsRow: View { + @Environment(\.themePalette) private var palette: ThemePalette + + let validate: () async throws -> Void + + @State private var isRunning = false + @State private var message: String? + @State private var failed = false + + var body: some View { + HStack(alignment: .center, spacing: Spacing.sm) { + Text(AppL10n.string("settings.provider.tools")) + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + + if isRunning { + ProgressView() + .controlSize(.mini) + } else if let message { + Text(message) + .font(TypeStyle.caption2) + .foregroundStyle(failed ? palette.danger : palette.accent) + .lineLimit(3) + .truncationMode(.tail) + } + + Spacer(minLength: 0) + + Button { + Task { await runValidate() } + } label: { + Text(AppL10n.string("settings.provider.validate")) + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + .padding(.horizontal, Spacing.md) + .frame(minHeight: 34) + .background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium)) + .overlay( + RoundedRectangle(cornerRadius: Radius.medium) + .stroke(palette.divider, lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + .disabled(isRunning) + } + .settingsListRow() + } + + @MainActor + private func runValidate() async { + isRunning = true + failed = false + defer { isRunning = false } + + let outcome = await ProviderToolRunner.runValidate( + runningMessage: AppL10n.string("api.test.running"), + successMessage: AppL10n.string("api.test.success"), + validate: validate + ) + message = outcome.message + failed = outcome.failed + } +} diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift index 15faaa9..5902b1b 100644 --- a/OSGKeyboard/Views/SettingsView.swift +++ b/OSGKeyboard/Views/SettingsView.swift @@ -43,15 +43,13 @@ struct SettingsView: View { dictionaryAndPolishSection flowSessionSection engineSection - polishProviderSection - polishApiSection if config.engineMode == "cloud" { - asrProviderSection - asrApiSection + asrSettingsSection } if config.engineMode == "local" { localEngineSettingsSection } + polishSettingsSection if presentation == .tab { footerLinks } @@ -115,9 +113,7 @@ struct SettingsView: View { } } .tint(palette.accent) - .padding(.horizontal, Spacing.md) - .padding(.vertical, Spacing.sm) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .settingsListRow() Divider().background(palette.divider) @@ -222,10 +218,6 @@ struct SettingsView: View { Divider().background(palette.divider) TranslationPickerRow(config: config, isVisible: config.isTranslationRowVisible) - - Divider().background(palette.divider) - - PersonalDictionaryICloudSyncRow() } .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) .overlay( @@ -248,40 +240,36 @@ struct SettingsView: View { } } - private var polishProviderSection: some View { + private var polishSettingsSection: some View { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { sectionHeader("settings.polishProvider.title") - Text("settings.polishProvider.subtitle") - .font(TypeStyle.caption2) - .foregroundStyle(palette.textTertiary) - .frame(maxWidth: .infinity, alignment: .leading) - ProviderPickerSection(config: config, role: .polish) + cloudProviderSettingsCard { + ProviderPickerSection(config: config, role: .polish, showsSurface: false) + Divider().background(palette.divider) + APISettingsCard(config: config, showsSurface: false) + } } } - private var asrProviderSection: some View { + private var asrSettingsSection: some View { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { sectionHeader("settings.asrProvider.title") - Text("settings.asrProvider.subtitle") - .font(TypeStyle.caption2) - .foregroundStyle(palette.textTertiary) - .frame(maxWidth: .infinity, alignment: .leading) - ProviderPickerSection(config: config, role: .asr) + cloudProviderSettingsCard { + ProviderPickerSection(config: config, role: .asr, showsSurface: false) + Divider().background(palette.divider) + ASRSettingsCard(config: config, showsSurface: false) + } } } - private var polishApiSection: some View { - VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { - sectionHeader("settings.polishApi.title") - APISettingsCard(config: config) - } - } - - private var asrApiSection: some View { - VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { - sectionHeader("settings.asrApi.title") - ASRSettingsCard(config: config) + @ViewBuilder + private func cloudProviderSettingsCard( + @ViewBuilder content: () -> Content + ) -> some View { + VStack(spacing: 0) { + content() } + .modifier(SettingsSurfaceCardModifier(enabled: true)) } // MARK: - Language helpers @@ -352,8 +340,7 @@ struct SettingsView: View { .foregroundStyle(palette.textPrimary) } .tint(palette.accent) - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .settingsListRow() } // MARK: - Footer links (tab settings only) @@ -375,8 +362,7 @@ struct SettingsView: View { .font(.system(size: 14, weight: .semibold)) .foregroundStyle(palette.textTertiary) } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .settingsListRow() .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -432,8 +418,7 @@ struct SettingsView: View { MaterialIcon(name: .openInNew, size: 18) .foregroundStyle(palette.textTertiary) } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .settingsListRow() .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -453,8 +438,7 @@ struct SettingsView: View { .font(.system(size: 14, weight: .semibold)) .foregroundStyle(palette.textTertiary) } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .settingsListRow() .contentShape(Rectangle()) } @@ -596,8 +580,7 @@ private struct PickerRow: View { } } } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .settingsListRow() } private var currentLabel: String { @@ -654,8 +637,7 @@ private struct LocalePickerRow: View { } } } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .settingsListRow() } private func label(for localeId: String) -> String { diff --git a/OSGKeyboard/Views/TranslationPickerRow.swift b/OSGKeyboard/Views/TranslationPickerRow.swift index f37abcc..22bdc35 100644 --- a/OSGKeyboard/Views/TranslationPickerRow.swift +++ b/OSGKeyboard/Views/TranslationPickerRow.swift @@ -69,13 +69,10 @@ struct TranslationPickerRow: View { } } } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .settingsListRow() } } - // MARK: - Selection plumbing - /// Currently selected id — the picker always reads /// `translationTargetLocaleId` directly (the previous /// `translationEnabled` boolean is now derived from it). diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index 70ad5f0..73f3d22 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -4,9 +4,7 @@ /* Onboarding */ "onboarding.progress" = "Step %1$d of %2$d"; -"onboarding.welcome.tagline" = "Speak instead of type"; -"onboarding.welcome.subtitle" = "Open-source voice keyboard"; -"onboarding.welcome.subtitle2" = "We never read or upload anything you type."; +"onboarding.welcome.tagline" = "Speak it. It’s typed."; "onboarding.permission.preface" = "iOS will ask for two permissions — please allow both."; "onboarding.permission.mic.title" = "Microphone"; "onboarding.permission.mic.body" = "Records your voice."; @@ -29,7 +27,10 @@ "onboarding.enable.step3.suffix" = "and select OSGKeyboard"; "onboarding.enable.openSettings" = "Open Settings"; "onboarding.api.title" = "Choose Engine"; -"onboarding.api.localModels.hint" = "Local engine uses built-in on-device speech recognition and built-in polish — no API key needed."; +"onboarding.api.localModels.hint" = "Local engine uses built-in on-device speech recognition — no API key needed."; +"onboarding.polish.title" = "Text polish (LLM)"; +"onboarding.polish.subtitle" = "Cleans up the transcript after recognition. Can differ from the ASR provider."; +"onboarding.polish.localHint" = "Built-in polish is included. Add your own API key here to override."; "settings.onboarding.replay" = "Restart permission setup"; /* Common navigation */ @@ -44,6 +45,7 @@ "common.delete" = "Delete"; "common.space" = "Space"; "common.newline" = "Return"; +"common.send" = "Send"; /* Privacy footnote (onboarding engine page) */ "privacy.audio.title" = "On-device transcription"; @@ -92,13 +94,13 @@ "settings.reset.title" = "Reset all settings?"; "settings.reset.message" = "API key, model, base URL, voice history, and cloud consent will be cleared."; "settings.reset.confirm" = "Reset all settings"; -"settings.engine.title" = "Recognition method"; -"settings.engine.subtitle" = "Local: on-device ASR only. Cloud: ASR + polish via your API."; -"settings.engine.local.title" = "On-device recognition"; +"settings.engine.title" = "Speech transcription method"; +"settings.engine.subtitle" = "Local: built-in system transcription. Cloud: ASR transcription with optional LLM polish."; +"settings.engine.local.title" = "On-device transcription"; "settings.engine.local.ios26" = "Always on-device, no network."; -"settings.engine.local.legacy" = "On-device ASR. Transcription only, no polish."; -"settings.engine.cloud.title" = "Cloud recognition & polish"; -"settings.engine.cloud.subtitle" = "Cloud ASR and polish LLM are configured separately. Audio goes to your ASR provider."; +"settings.engine.local.legacy" = "Transcribe with the built-in system speech service"; +"settings.engine.cloud.title" = "Cloud transcription"; +"settings.engine.cloud.subtitle" = "Cloud ASR transcription, with optional LLM polish"; "settings.engine.cloud.badge" = "Cloud engine"; "settings.provider.title" = "Provider"; "settings.provider.personalDictionaryBadge" = "Personal dictionary"; @@ -111,6 +113,26 @@ "settings.asrApi.title" = "ASR API"; "settings.asr.model" = "ASR model"; "settings.asr.testConnection" = "Test ASR"; +"settings.provider.supplier" = "Provider"; +"settings.provider.tools" = "Connection check"; +"settings.provider.validate" = "Validate"; +"settings.provider.fetchModels" = "Fetch models"; +"settings.provider.loadingModels" = "Loading models…"; +"settings.provider.modelsLoaded" = "%lld models loaded"; +"settings.provider.selectModel" = "Select model"; +"settings.provider.modelSelected" = "Selected %@"; +"settings.provider.modelsEmptyHint" = "Tap refresh to load models"; +"settings.provider.fillDefault" = "Fill default"; +"settings.provider.thinking" = "Thinking"; +"settings.provider.thinkingSubtitle" = "Slower, higher quality — recommended off"; +"settings.provider.thinkingOn" = "On"; +"settings.provider.thinkingOff" = "Off"; +"settings.provider.thinkingHint" = "Off by default. Turn on only when you want slower, deeper reasoning."; +"settings.provider.note" = "Note"; +"settings.asr.volcengine.appId" = "APP ID"; +"settings.asr.volcengine.accessToken" = "Access Token"; +"settings.asr.volcengine.resourceId" = "Resource ID"; +"settings.asr.volcengine.note" = "Secret Key is not required. Resource ID defaults to volc.seedasr.sauc.duration."; "provider.openai" = "OpenAI"; "provider.deepseek" = "DeepSeek"; "provider.qwen" = "Qwen (DashScope)"; @@ -313,16 +335,8 @@ "home.flow.startShort" = "Start"; "home.preview.label" = "Try typing"; "home.preview.placeholder" = "Tap to type and test…"; -"home.stats.dictationDuration" = "Dictation time"; -"home.stats.dictationCharacters" = "Dictation chars"; -"home.stats.translationCharacters" = "Translation chars"; -"home.stats.dictionaryEntries" = "Dictionary"; "home.wide.tagline" = "Voice dictation, anywhere."; "home.wide.tagline.subtitle" = "Switch to any app and tap the keyboard mic to dictate."; -"home.wide.stat.transcribed" = "Total dictated"; -"home.wide.stat.cumulativeDuration" = "Cumulative duration"; -"home.wide.stat.cumulativeTranslation" = "Cumulative translation"; -"home.wide.stat.customTerms" = "Custom terms"; "home.wide.mode.cloud" = "Cloud"; "home.wide.mode.local" = "On-device"; "home.wide.devices" = "iPhone & iPad"; @@ -387,10 +401,12 @@ "settings.personalDictionary.iCloudSync.error.tooLarge" = "Dictionary is too large to sync via iCloud. Remove some entries and try again."; "settings.personalDictionary.iCloudSync.error.generic" = "Could not sync your dictionary with iCloud. Try again later."; -"settings.iCloudSync.title" = "iCloud Sync"; -"settings.appSettings.iCloudSync.title" = "iCloud Sync"; -"settings.appSettings.iCloudSync.subtitle" = "Sync settings, usage stats, speech history, and API keys across your devices via iCloud. API keys use your private iCloud Keychain."; +"settings.iCloudSync.title" = "Cross-Device iCloud Sync"; +"settings.appSettings.iCloudSync.title" = "Cross-Device iCloud Sync"; +"settings.appSettings.iCloudSync.subtitle" = "Sync settings, history, and API keys across devices via iCloud."; "settings.appSettings.iCloudSync.syncNow" = "Sync Now"; +"settings.appSettings.iCloudSync.syncing" = "Syncing…"; +"settings.appSettings.iCloudSync.synced" = "Synced"; "settings.appSettings.iCloudSync.error.generic" = "Could not sync settings with iCloud. Try again later."; /* v0.3.0: Polish intensity */ diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index 2afcbe0..3fef6c6 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -4,9 +4,7 @@ /* Onboarding */ "onboarding.progress" = "第 %1$d / %2$d 步"; -"onboarding.welcome.tagline" = "能说就不打字"; -"onboarding.welcome.subtitle" = "开源语音输入法"; -"onboarding.welcome.subtitle2" = "不读取、不上传任何输入内容。"; +"onboarding.welcome.tagline" = "开口即文字。"; "onboarding.permission.preface" = "系统会弹出两次授权,请「允许」权限申请。"; "onboarding.permission.mic.title" = "麦克风"; "onboarding.permission.mic.body" = "用于录制语音。"; @@ -29,7 +27,10 @@ "onboarding.enable.step3.suffix" = ",选中 OSGKeyboard"; "onboarding.enable.openSettings" = "去设置"; "onboarding.api.title" = "选择语音转文字 AI 引擎"; -"onboarding.api.localModels.hint" = "本地引擎使用内置语音识别与内置润色,无需填写 API Key。"; +"onboarding.api.localModels.hint" = "本地引擎使用内置语音识别,无需填写 API Key。"; +"onboarding.polish.title" = "文本润色(LLM)"; +"onboarding.polish.subtitle" = "识别完成后整理文字,可与 ASR 服务商不同。"; +"onboarding.polish.localHint" = "已包含内置润色。在此填入 API Key 可替换为自定义模型。"; "settings.onboarding.replay" = "重新开始权限引导"; /* Common navigation */ @@ -43,7 +44,8 @@ "common.clear" = "清空"; "common.delete" = "删除"; "common.space" = "空格"; -"common.newline" = "换行"; +"common.newline" = "回车"; +"common.send" = "发送"; /* Privacy footnote (onboarding engine page) */ "privacy.audio.title" = "支持本地转写"; @@ -92,13 +94,13 @@ "settings.reset.title" = "重置所有设置?"; "settings.reset.message" = "将清空 API Key、模型、接口地址、语音历史及云端确认状态。"; "settings.reset.confirm" = "重置所有设置"; -"settings.engine.title" = "识别方式"; -"settings.engine.subtitle" = "本地:端侧识别,仅转录;云端:通过 API 润色。"; -"settings.engine.local.title" = "本地识别"; +"settings.engine.title" = "语音转写方式"; +"settings.engine.subtitle" = "本地:系统内置转写;云端:ASR 转写,可用 LLM 润色。"; +"settings.engine.local.title" = "本地转写"; "settings.engine.local.ios26" = "全程在手机本地,不用联网"; -"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。"; -"settings.engine.cloud.title" = "云端识别与润色"; -"settings.engine.cloud.subtitle" = "云端 ASR 与润色 LLM 分开配置;音频发送至转写服务商。"; +"settings.engine.local.legacy" = "通过系统内置服务语音转写"; +"settings.engine.cloud.title" = "云端转写"; +"settings.engine.cloud.subtitle" = "通过云端 ASR 转写,可用 LLM 润色"; "settings.engine.cloud.badge" = "云端引擎"; "settings.provider.title" = "云端引擎"; "settings.provider.personalDictionaryBadge" = "个性词库"; @@ -111,6 +113,26 @@ "settings.asrApi.title" = "转写接口"; "settings.asr.model" = "ASR 模型"; "settings.asr.testConnection" = "测试转写"; +"settings.provider.supplier" = "供应商"; +"settings.provider.tools" = "连接检查"; +"settings.provider.validate" = "验证"; +"settings.provider.fetchModels" = "拉取模型"; +"settings.provider.loadingModels" = "正在拉取模型…"; +"settings.provider.modelsLoaded" = "已拉取 %lld 个模型"; +"settings.provider.selectModel" = "选择模型"; +"settings.provider.modelSelected" = "已选择 %@"; +"settings.provider.modelsEmptyHint" = "先点右侧刷新拉取模型"; +"settings.provider.fillDefault" = "填入默认值"; +"settings.provider.thinking" = "思考"; +"settings.provider.thinkingSubtitle" = "速度更慢、质量更高,建议关闭"; +"settings.provider.thinkingOn" = "开启"; +"settings.provider.thinkingOff" = "关闭"; +"settings.provider.thinkingHint" = "默认关闭。仅在需要更慢、更深的推理时开启。"; +"settings.provider.note" = "提示"; +"settings.asr.volcengine.appId" = "APP ID"; +"settings.asr.volcengine.accessToken" = "Access Token"; +"settings.asr.volcengine.resourceId" = "Resource ID"; +"settings.asr.volcengine.note" = "Secret Key 当前无需填写。Resource ID 默认使用 volc.seedasr.sauc.duration。"; "provider.openai" = "OpenAI"; "provider.deepseek" = "DeepSeek"; "provider.qwen" = "通义千问"; @@ -312,16 +334,8 @@ "home.flow.startShort" = "开启"; "home.preview.label" = "输入测试"; "home.preview.placeholder" = "点这里试试键盘"; -"home.stats.dictationDuration" = "听写时长"; -"home.stats.dictationCharacters" = "听写字数"; -"home.stats.translationCharacters" = "翻译字数"; -"home.stats.dictionaryEntries" = "个性词库"; "home.wide.tagline" = "随处语音听写"; "home.wide.tagline.subtitle" = "切换到任意 App,点键盘麦克风即可听写。"; -"home.wide.stat.transcribed" = "累计听写字数"; -"home.wide.stat.cumulativeDuration" = "累计听写时长"; -"home.wide.stat.cumulativeTranslation" = "累计翻译字数"; -"home.wide.stat.customTerms" = "自定义词条"; "home.wide.mode.cloud" = "云端"; "home.wide.mode.local" = "本机"; "home.wide.devices" = "iPhone 与 iPad"; @@ -386,10 +400,12 @@ "settings.personalDictionary.iCloudSync.error.tooLarge" = "词库过大,无法通过 iCloud 同步。请删除部分词条后重试。"; "settings.personalDictionary.iCloudSync.error.generic" = "无法与 iCloud 同步词库,请稍后重试。"; -"settings.iCloudSync.title" = "iCloud 同步"; -"settings.appSettings.iCloudSync.title" = "iCloud 同步"; -"settings.appSettings.iCloudSync.subtitle" = "通过 iCloud 在多台设备间同步设置、使用统计、语音历史与 API 密钥。API 密钥经私人 iCloud 钥匙串同步。"; +"settings.iCloudSync.title" = "跨设备iCloud 同步"; +"settings.appSettings.iCloudSync.title" = "跨设备iCloud 同步"; +"settings.appSettings.iCloudSync.subtitle" = "通过 iCloud 跨设备同步设置、历史记录、API Key"; "settings.appSettings.iCloudSync.syncNow" = "立即同步"; +"settings.appSettings.iCloudSync.syncing" = "同步中…"; +"settings.appSettings.iCloudSync.synced" = "已同步"; "settings.appSettings.iCloudSync.error.generic" = "无法与 iCloud 同步设置,请稍后重试。"; /* v0.3.0: 润色强度 */ diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index 99847f4..a878341 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -105,6 +105,12 @@ public final class KeyboardViewController: UIInputViewController { super.viewDidAppear(animated) disableSystemGestureDelays() keyboardHeightConstraint?.constant = targetKeyboardHeight + refreshReturnKeyRole() + } + + public override func textDidChange(_ textInput: UITextInput?) { + super.textDidChange(textInput) + refreshReturnKeyRole() } public override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { @@ -195,6 +201,21 @@ public final class KeyboardViewController: UIInputViewController { } } + private func refreshReturnKeyRole() { + state.returnKeyRole = returnKeyRole(for: textDocumentProxy.returnKeyType ?? .default) + } + + private func returnKeyRole(for returnKeyType: UIReturnKeyType) -> State.ReturnKeyRole { + switch returnKeyType { + case .send, .go, .search, .join, .route, .google, .yahoo, .continue, .emergencyCall: + return .send + case .default, .next, .done: + return .newline + @unknown default: + return .newline + } + } + // MARK: - System keyboard chrome private func configureDictationBehavior() { diff --git a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift index 0899e3b..2b1bd2d 100644 --- a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift +++ b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift @@ -48,8 +48,21 @@ final class KeyboardFlowCoordinator { /// `adoptHostBusyStateIfNeeded` from re-entering `.processing` after a /// stale App Group snapshot still says `reason=processing`. private var lastConsumedUtteranceId: UUID? + /// Utterance we just asked the host to stop. Until the host publishes + /// processing/final state, stale App Group snapshots can still say + /// `reason=recording`; do not re-adopt that utterance as locally active. + private var lastStoppedUtteranceId: UUID? private var currentCommandSeq: Int64 = 0 private var lastAvailabilityTraceSignature = "" + /// When true, `completeFlowStartHandoff` starts recording after the host + /// publishes ready — set only for an explicit mic press. + private var recordAfterHandoff = false + /// When true, `startHostReadyWaitIfNeeded` starts recording once ready + /// (mic pressed while session was still warming / mid ready-flap). + private var recordWhenHostReady = false + /// Ignores single-frame "host dead" samples before allowing a cold-start jump + /// from non-press recovery paths. + private var coldStartDebouncer = FlowColdStartDebouncer() init( state: KeyboardState, @@ -120,6 +133,8 @@ final class KeyboardFlowCoordinator { recomputeMicVoiceAvailability() startHostReadyWaitIfNeeded() + // Proactive host auto-launch is disabled (FlowHandoffPolicy): a single + // stale ready snapshot after finalize must never open startflow. // Only surface "session ended" when the session contract *genuinely* // dropped (expired / cleared). A transient host-ready flap — engine @@ -221,6 +236,7 @@ final class KeyboardFlowCoordinator { // forever miss the real delivery and leaves the mic white forever. guard let busyId = snapshot.busyUtteranceId else { return } guard busyId != lastConsumedUtteranceId else { return } + guard busyId != lastStoppedUtteranceId else { return } activeSessionId = sessionId currentUtteranceId = busyId isPendingFlowStart = false @@ -268,6 +284,7 @@ final class KeyboardFlowCoordinator { state.lastTranscript = "" stopFlowWatchdog() currentUtteranceId = nil + lastStoppedUtteranceId = nil traceState( "stickyProcessing.cleared", extra: hostReady ? "hostReady=1" : "hostReady=0" @@ -280,16 +297,24 @@ final class KeyboardFlowCoordinator { guard !isPendingFlowStart else { return } guard FlowSessionBridge.isSessionActive() else { stopHostReadyWait() + if recordWhenHostReady { + // Session gone while waiting — escalate to a real cold start. + let shouldRecord = recordWhenHostReady + recordWhenHostReady = false + beginFlowStart(recordAfterHandoff: shouldRecord) + } return } // Host busy ≠ waiting for ready. Do not spin the ready-wait poll. if let reason = FlowSessionBridge.readySnapshot()?.reason, reason == .recording || reason == .processing { stopHostReadyWait() + recordWhenHostReady = false return } - guard !FlowSessionBridge.isHostReady() else { + if FlowSessionBridge.isHostReady() { stopHostReadyWait() + finishHostReadyWaitIfNeeded() return } @@ -300,16 +325,50 @@ final class KeyboardFlowCoordinator { guard let self, !Task.isCancelled else { return } FlowSessionBridge.reloadFromDisk() self.recomputeMicVoiceAvailability() - if self.state.micVoiceAvailability.isReady - || self.state.micVoiceAvailability == .recording + if self.state.micVoiceAvailability.isReady { + self.finishHostReadyWaitIfNeeded() + return + } + if self.state.micVoiceAvailability == .recording || self.state.micVoiceAvailability == .processing { + self.recordWhenHostReady = false + return + } + // Host died mid-wait — only cold-start after debounced dead samples. + let dead = FlowHandoffPolicy.shouldOpenHostColdStart( + sessionActive: FlowSessionBridge.isSessionActive(), + hostReachable: FlowSessionBridge.isHostReachable(), + hostStale: FlowSessionBridge.isHostStale(), + withinReadyGrace: false + ) + if self.coldStartDebouncer.observe(hostTrulyDead: dead) { + let shouldRecord = self.recordWhenHostReady + self.recordWhenHostReady = false + self.coldStartDebouncer.reset() + self.beginFlowStart(recordAfterHandoff: shouldRecord) return } try? await Task.sleep(nanoseconds: 150_000_000) } + // Timed out still not ready — if the user asked to record, cold-start. + guard let self else { return } + if self.recordWhenHostReady { + let shouldRecord = self.recordWhenHostReady + self.recordWhenHostReady = false + self.beginFlowStart(recordAfterHandoff: shouldRecord) + } } } + private func finishHostReadyWaitIfNeeded() { + coldStartDebouncer.reset() + guard recordWhenHostReady else { return } + recordWhenHostReady = false + guard state.micVoiceAvailability.isReady else { return } + startFlowRecording() + traceState("hostReadyWait.recordStarted") + } + private func stopHostReadyWait() { hostReadyWaitTask?.cancel() hostReadyWaitTask = nil @@ -338,9 +397,6 @@ final class KeyboardFlowCoordinator { recomputeMicVoiceAvailability() switch state.micVoiceAvailability { - case .ready: - detectAndStoreAppContext() - startFlowRecording() case .unavailable(.missingAPIKey): return case .unavailable(.noFullAccess): @@ -348,18 +404,43 @@ final class KeyboardFlowCoordinator { state.phase = .error(.fullAccessRequired, message: msg) scheduleAutoClearError() recomputeMicVoiceAvailability() + return case .unavailable(.appGroupUnavailable): let msg = ExtL10n.string("keyboard.error.appGroupCommunication") state.phase = .error(.appGroupUnavailable, message: msg) scheduleAutoClearError() recomputeMicVoiceAvailability() - case .unavailable(.preparingSession): + return + default: + break + } + + let withinReadyGrace = lastHostReadyAt > 0 + && (Date().timeIntervalSince1970 - lastHostReadyAt) <= Self.hostReadyGrace + let action = FlowHandoffPolicy.micPressAction( + availability: state.micVoiceAvailability, + sessionActive: FlowSessionBridge.isSessionActive(), + hostReachable: FlowSessionBridge.isHostReachable(), + hostStale: FlowSessionBridge.isHostStale(), + withinReadyGrace: withinReadyGrace + ) + switch action { + case .startRecording: detectAndStoreAppContext() - beginFlowStart() - case .unavailable(.hostNotReady): + startFlowRecording() + case .waitForHostReady(let recordWhenReady): detectAndStoreAppContext() - beginFlowStart() - case .recording, .processing: + recordWhenHostReady = recordWhenReady + coldStartDebouncer.reset() + startHostReadyWaitIfNeeded() + traceState( + "pressBegan.waitForHostReady", + extra: recordWhenReady ? "recordWhenReady=1" : "recordWhenReady=0" + ) + case .openHostColdStart: + detectAndStoreAppContext() + beginFlowStart(recordAfterHandoff: true) + case .ignore: return } } @@ -374,19 +455,23 @@ final class KeyboardFlowCoordinator { isFlowRecording = false stopUtteranceCountdown() ExtensionScreenWakeLock.release() + lastStoppedUtteranceId = currentUtteranceId writeCommand(.stopRecording) debug("pressEnded wrote stop command") state.phase = .processing state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing") - recomputeMicVoiceAvailability() startFlowResultWatchdog() + recomputeMicVoiceAvailability() } - func beginFlowStart() { + func beginFlowStart(recordAfterHandoff: Bool = false) { guard !isPendingFlowStart else { traceState("beginFlowStart.ignored", extra: "reason=pendingAlreadyTrue") return } + self.recordAfterHandoff = recordAfterHandoff + recordWhenHostReady = false + coldStartDebouncer.reset() isPendingFlowStart = true isFlowRecording = false flowStartDeadline = Date().timeIntervalSince1970 + FlowWatchdog.startTimeout @@ -394,7 +479,10 @@ final class KeyboardFlowCoordinator { recomputeMicVoiceAvailability() openHostApp("startflow") startFlowStartWatchdog() - traceState("beginFlowStart.started") + traceState( + "beginFlowStart.started", + extra: recordAfterHandoff ? "recordAfterHandoff=1" : "recordAfterHandoff=0" + ) } func handleHostAppOpenResult(path: String, success: Bool) { @@ -406,6 +494,7 @@ final class KeyboardFlowCoordinator { // guide the user to open OSGKeyboard manually. if path == "startflow", isPendingFlowStart { isPendingFlowStart = false + recordAfterHandoff = false flowStartDeadline = 0 stopFlowWatchdog() traceState("openHostApp.failed", extra: "path=startflow cancelPending=1") @@ -425,8 +514,10 @@ final class KeyboardFlowCoordinator { ExtensionScreenWakeLock.release() } currentUtteranceId = nil + lastStoppedUtteranceId = nil isFlowRecording = false isPendingFlowStart = false + recordAfterHandoff = false stopUtteranceCountdown() stopFlowWatchdog() state.level = 0 @@ -472,6 +563,7 @@ final class KeyboardFlowCoordinator { ) FlowSessionBridge.clearResult() lastConsumedUtteranceId = result.utteranceId + lastStoppedUtteranceId = nil currentUtteranceId = nil textInserter.handleFlowTranscript( TranscriptionDelivery(text: text, polishWarning: result.warning) @@ -483,6 +575,7 @@ final class KeyboardFlowCoordinator { stopFlowWatchdog() FlowSessionBridge.clearResult() lastConsumedUtteranceId = result.utteranceId + lastStoppedUtteranceId = nil currentUtteranceId = nil let error = FlowTranscriptionError( message: result.text ?? ExtL10n.string("keyboard.flow.resultTimeout"), @@ -528,6 +621,7 @@ final class KeyboardFlowCoordinator { ExtensionScreenWakeLock.release() writeCommand(.abort) currentUtteranceId = nil + lastStoppedUtteranceId = nil stopFlowWatchdog() state.level = 0 state.phase = .idle @@ -547,10 +641,12 @@ final class KeyboardFlowCoordinator { isAwaitingFlowResult = false isFlowRecording = false isPendingFlowStart = false + recordAfterHandoff = false stopUtteranceCountdown() ExtensionScreenWakeLock.release() writeCommand(.abort) currentUtteranceId = nil + lastStoppedUtteranceId = nil stopFlowWatchdog() state.level = 0 let message = ExtL10n.string("keyboard.flow.hostDisconnected") @@ -585,9 +681,29 @@ final class KeyboardFlowCoordinator { private func startFlowRecording() { recomputeMicVoiceAvailability() - guard state.micVoiceAvailability.isReady else { - traceState("startFlowRecording.blocked", extra: "availability=\(String(describing: state.micVoiceAvailability))") - beginFlowStart() + let withinReadyGrace = lastHostReadyAt > 0 + && (Date().timeIntervalSince1970 - lastHostReadyAt) <= Self.hostReadyGrace + if !state.micVoiceAvailability.isReady { + let action = FlowHandoffPolicy.micPressAction( + availability: state.micVoiceAvailability, + sessionActive: FlowSessionBridge.isSessionActive(), + hostReachable: FlowSessionBridge.isHostReachable(), + hostStale: FlowSessionBridge.isHostStale(), + withinReadyGrace: withinReadyGrace + ) + traceState( + "startFlowRecording.blocked", + extra: "availability=\(String(describing: state.micVoiceAvailability)) action=\(action)" + ) + switch action { + case .waitForHostReady(let recordWhenReady): + recordWhenHostReady = recordWhenReady + startHostReadyWaitIfNeeded() + case .openHostColdStart: + beginFlowStart(recordAfterHandoff: true) + case .startRecording, .ignore: + break + } return } isPendingFlowStart = false @@ -596,11 +712,23 @@ final class KeyboardFlowCoordinator { guard let sessionId = FlowSessionBridge.readySnapshot()?.sessionId else { traceState("startFlowRecording.blocked", extra: "reason=missingSessionIdInReadySnapshot") - beginFlowStart() + // Snapshot lag with a live session → wait; only cold-start if host is dead. + if FlowHandoffPolicy.shouldOpenHostColdStart( + sessionActive: FlowSessionBridge.isSessionActive(), + hostReachable: FlowSessionBridge.isHostReachable(), + hostStale: FlowSessionBridge.isHostStale(), + withinReadyGrace: withinReadyGrace + ) { + beginFlowStart(recordAfterHandoff: true) + } else { + recordWhenHostReady = true + startHostReadyWaitIfNeeded() + } return } activeSessionId = sessionId currentUtteranceId = UUID() + lastStoppedUtteranceId = nil writeCommand(.startRecording) isFlowRecording = true state.lastTranscript = "" @@ -640,8 +768,12 @@ final class KeyboardFlowCoordinator { private func cancelPendingFlowStart() { isPendingFlowStart = false + recordAfterHandoff = false + recordWhenHostReady = false flowStartDeadline = 0 + coldStartDebouncer.reset() stopFlowWatchdog() + stopHostReadyWait() state.phase = .idle state.lastTranscript = "" recomputeMicVoiceAvailability() @@ -660,6 +792,7 @@ final class KeyboardFlowCoordinator { let now = Date().timeIntervalSince1970 if self.flowStartDeadline > 0, now > self.flowStartDeadline { self.isPendingFlowStart = false + self.recordAfterHandoff = false self.flowStartDeadline = 0 self.traceState("startWatchdog.timeout") self.showManualOpenHint(path: "startflow") @@ -671,13 +804,20 @@ final class KeyboardFlowCoordinator { } private func completeFlowStartHandoff() { + let shouldRecord = recordAfterHandoff isPendingFlowStart = false + recordAfterHandoff = false flowStartDeadline = 0 stopFlowWatchdog() state.lastTranscript = "" refreshSessionState() - startFlowRecording() - traceState("completeFlowStartHandoff.done") + if shouldRecord { + startFlowRecording() + traceState("completeFlowStartHandoff.done", extra: "record=1") + } else { + recomputeMicVoiceAvailability() + traceState("completeFlowStartHandoff.done", extra: "record=0 warmOnly") + } } private func startFlowLevelWatchdog() { @@ -735,6 +875,7 @@ final class KeyboardFlowCoordinator { ) FlowSessionBridge.clearResult() self.lastConsumedUtteranceId = result.utteranceId + self.lastStoppedUtteranceId = nil self.currentUtteranceId = nil self.debug("resultWatchdog consumed delivery len=\(text.count)") self.textInserter.handleFlowTranscript( @@ -747,6 +888,7 @@ final class KeyboardFlowCoordinator { self.stopFlowWatchdog() FlowSessionBridge.clearResult() self.lastConsumedUtteranceId = result.utteranceId + self.lastStoppedUtteranceId = nil self.currentUtteranceId = nil let error = FlowTranscriptionError( message: result.text ?? ExtL10n.string("keyboard.flow.resultTimeout"), @@ -784,6 +926,7 @@ final class KeyboardFlowCoordinator { self.isAwaitingFlowResult = false self.stopFlowWatchdog() self.currentUtteranceId = nil + self.lastStoppedUtteranceId = nil self.debug("resultWatchdog TIMEOUT after \(Int(resultTimeout))s — no result from host") let msg = ExtL10n.string("keyboard.flow.resultTimeout") self.state.phase = .error(.flowResultTimeout, message: msg) diff --git a/OSGKeyboardExt/Views/KeyboardRootView.swift b/OSGKeyboardExt/Views/KeyboardRootView.swift index f4c0bb5..2bcc2cc 100644 --- a/OSGKeyboardExt/Views/KeyboardRootView.swift +++ b/OSGKeyboardExt/Views/KeyboardRootView.swift @@ -12,7 +12,7 @@ // │ (transcript preview) │ // │ ┊ │ // │ ◯ mic (centred) │ ← action cluster: -// │ [delete] [ space ] [return] │ mic + bottom row +// │ [delete] [ return ] [space] │ mic + bottom row // │ ┊ │ // └───────────────────────────────────────────┘ @@ -191,7 +191,7 @@ public struct KeyboardRootView: View { // MARK: - Action cluster - /// Mic centred above a bottom row: delete · space · return (or swapped). + /// Mic centred above a bottom row: delete · smart return · space (or swapped). /// The side cursor-drag pads are SwiftUI layout wrappers around UIKit /// pan recognizers, avoiding SwiftUI gesture delivery issues in /// keyboard extensions. @@ -225,13 +225,13 @@ public struct KeyboardRootView: View { HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) { if swapKeys { - bottomReturnButton(disabled: editingBlocked) bottomSpaceButton(disabled: editingBlocked) + bottomReturnButton(disabled: editingBlocked) bottomDeleteButton(disabled: editingBlocked) } else { bottomDeleteButton(disabled: editingBlocked) - bottomSpaceButton(disabled: editingBlocked) bottomReturnButton(disabled: editingBlocked) + bottomSpaceButton(disabled: editingBlocked) } } .opacity(dragging ? 0 : 1) @@ -265,19 +265,20 @@ public struct KeyboardRootView: View { RectangularToolbarButton(spaceStyle: true, label: "space", disabled: disabled) { state.insertSpace() } - .frame(height: KeyboardLayoutMetrics.bottomActionRowHeight) - } - - private func bottomReturnButton(disabled: Bool) -> some View { - RectangularToolbarButton(systemName: "return", label: "newline", disabled: disabled) { - state.insertNewline() - } .frame( width: KeyboardLayoutMetrics.bottomActionFixedWidth, height: KeyboardLayoutMetrics.bottomActionRowHeight ) } + private func bottomReturnButton(disabled: Bool) -> some View { + let title = ExtL10n.string(state.returnKeyRole.titleKey) + return RectangularToolbarButton(title: title, label: title, disabled: disabled) { + state.insertNewline() + } + .frame(height: KeyboardLayoutMetrics.bottomActionRowHeight) + } + /// Option C: block typing keys during the full voice-input pipeline. private var voiceInputBlocksEditing: Bool { switch state.phase { diff --git a/OSGKeyboardExt/Views/ToolbarActionButtons.swift b/OSGKeyboardExt/Views/ToolbarActionButtons.swift index 1a69249..e27ef6c 100644 --- a/OSGKeyboardExt/Views/ToolbarActionButtons.swift +++ b/OSGKeyboardExt/Views/ToolbarActionButtons.swift @@ -10,6 +10,7 @@ import OSGKeyboardShared private enum ToolbarButtonMetrics { static let iconSize: CGFloat = 14 + static let titleSize: CGFloat = 16 static let cornerRadius: CGFloat = 12 static let spaceBarCapsuleWidth: CGFloat = 31 static let pressScale: CGFloat = 0.94 @@ -138,6 +139,7 @@ struct RectangularToolbarButton: View { let systemName: String? let spaceStyle: Bool + let title: String? let label: String let disabled: Bool let action: () -> Void @@ -145,14 +147,25 @@ struct RectangularToolbarButton: View { init(systemName: String, label: String, disabled: Bool = false, action: @escaping () -> Void) { self.systemName = systemName self.spaceStyle = false + self.title = nil self.label = label self.disabled = disabled self.action = action } + init(title: String, label: String, disabled: Bool = false, action: @escaping () -> Void) { + self.systemName = nil + self.spaceStyle = false + self.label = label + self.disabled = disabled + self.action = action + self.title = title + } + init(spaceStyle: Bool, label: String, disabled: Bool = false, action: @escaping () -> Void) { self.systemName = nil self.spaceStyle = spaceStyle + self.title = nil self.label = label self.disabled = disabled self.action = action @@ -170,6 +183,10 @@ struct RectangularToolbarButton: View { Image(systemName: systemName) .font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold)) .foregroundStyle(palette.textPrimary) + } else if let title { + Text(title) + .font(.system(size: ToolbarButtonMetrics.titleSize, weight: .semibold)) + .foregroundStyle(palette.textPrimary) } } .contentShape(Rectangle()) diff --git a/OSGKeyboardExt/en.lproj/Keyboard.strings b/OSGKeyboardExt/en.lproj/Keyboard.strings index 581eb9d..a1642f4 100644 --- a/OSGKeyboardExt/en.lproj/Keyboard.strings +++ b/OSGKeyboardExt/en.lproj/Keyboard.strings @@ -25,6 +25,7 @@ "common.delete" = "Delete"; "common.space" = "Space"; "common.newline" = "Return"; +"common.send" = "Send"; /* Privacy footnote (onboarding welcome page) */ "privacy.audio.title" = "On-device transcription"; @@ -69,13 +70,13 @@ "settings.reset.title" = "Reset all settings?"; "settings.reset.message" = "API key, model, and base URL will be cleared."; "settings.reset.confirm" = "Reset all settings"; -"settings.engine.title" = "Recognition method"; -"settings.engine.subtitle" = "Pick the recognition engine. Local engine does transcription only, no API key needed."; -"settings.engine.local.title" = "On-device recognition"; +"settings.engine.title" = "Speech transcription method"; +"settings.engine.subtitle" = "Local: built-in system transcription. Cloud: ASR transcription with optional LLM polish."; +"settings.engine.local.title" = "On-device transcription"; "settings.engine.local.ios26" = "Always on-device, no network."; -"settings.engine.local.legacy" = "On-device ASR. Transcription only, no polish."; -"settings.engine.cloud.title" = "Cloud recognition & polish"; -"settings.engine.cloud.subtitle" = "ASR + LLM polish. API key required."; +"settings.engine.local.legacy" = "Transcribe with the built-in system speech service"; +"settings.engine.cloud.title" = "Cloud transcription"; +"settings.engine.cloud.subtitle" = "Cloud ASR transcription, with optional LLM polish"; "settings.provider.title" = "Provider"; "settings.provider.subtitle" = "Pick the LLM that polishes your dictation."; "settings.api.title" = "API"; diff --git a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings index 630ea1d..c5f5134 100644 --- a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings +++ b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings @@ -24,7 +24,8 @@ "common.cancel" = "取消"; "common.delete" = "删除"; "common.space" = "空格"; -"common.newline" = "换行"; +"common.newline" = "回车"; +"common.send" = "发送"; /* Privacy footnote (onboarding welcome page) */ "privacy.audio.title" = "支持本地转写"; @@ -69,13 +70,13 @@ "settings.reset.title" = "重置所有设置?"; "settings.reset.message" = "API key、model 和 base URL 都会被清空。"; "settings.reset.confirm" = "重置所有设置"; -"settings.engine.title" = "识别方式"; -"settings.engine.subtitle" = "选择识别方式。本地引擎无需 API Key,仅做语音转录。"; -"settings.engine.local.title" = "本地识别"; +"settings.engine.title" = "语音转写方式"; +"settings.engine.subtitle" = "本地:系统内置转写;云端:ASR 转写,可用 LLM 润色。"; +"settings.engine.local.title" = "本地转写"; "settings.engine.local.ios26" = "始终端侧,无需联网。"; -"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。"; -"settings.engine.cloud.title" = "云端识别与润色"; -"settings.engine.cloud.subtitle" = "ASR 转录 + LLM 润色,需要 API Key。"; +"settings.engine.local.legacy" = "通过系统内置服务语音转写"; +"settings.engine.cloud.title" = "云端转写"; +"settings.engine.cloud.subtitle" = "通过云端 ASR 转写,可用 LLM 润色"; "settings.provider.title" = "云端引擎"; "settings.provider.subtitle" = "选择 LLM 提供商。"; "settings.api.title" = "接口"; diff --git a/OSGKeyboardLiveActivity/FlowLiveActivityWidget.swift b/OSGKeyboardLiveActivity/FlowLiveActivityWidget.swift index f4eb6e6..a91b079 100644 --- a/OSGKeyboardLiveActivity/FlowLiveActivityWidget.swift +++ b/OSGKeyboardLiveActivity/FlowLiveActivityWidget.swift @@ -66,7 +66,7 @@ private struct FlowLiveActivityLockScreenView: View { var body: some View { HStack(spacing: 12) { - FlowLiveActivityBrandMark(height: 13) + FlowLiveActivityBrandMark(height: 10.4) VStack(alignment: .leading, spacing: 4) { Text("OSGKeyboard") .font(.headline) diff --git a/OSGKeyboardMac/DashboardView.swift b/OSGKeyboardMac/DashboardView.swift index a7ff65e..d83d9ee 100644 --- a/OSGKeyboardMac/DashboardView.swift +++ b/OSGKeyboardMac/DashboardView.swift @@ -22,23 +22,32 @@ struct DashboardView: View { } var body: some View { - VStack(spacing: 0) { - VStack(alignment: .leading, spacing: Spacing.lg) { - heroHeader - statCluster - dictationStage + // Scrollable like the other pages so the shared status footer always + // stays pinned to the window's bottom edge. `minHeight: viewport` keeps + // the balanced Spacer layout when the window is tall (no scrollbar) and + // lets the content scroll only when the window is too short to fit it. + GeometryReader { proxy in + ScrollView { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: Spacing.lg) { + heroHeader + statCluster + dictationStage + } + .padding(.horizontal, MacMetrics.pageHorizontalInset) + .padding(.top, Spacing.sm) + + // Leftover window height splits evenly above / below the mic + // bar so spacing stays balanced at any window size. + Spacer(minLength: Spacing.xs) + + BottomDictationBar(viewModel: viewModel) + .padding(.horizontal, MacMetrics.pageHorizontalInset) + + Spacer(minLength: Spacing.xs) + } + .frame(maxWidth: .infinity, minHeight: proxy.size.height) } - .padding(.horizontal, MacMetrics.pageHorizontalInset) - .padding(.top, Spacing.sm) - - // Leftover window height splits evenly above / below the mic bar - // so spacing stays balanced at any window size. - Spacer(minLength: Spacing.xs) - - BottomDictationBar(viewModel: viewModel) - .padding(.horizontal, MacMetrics.pageHorizontalInset) - - Spacer(minLength: Spacing.xs) } .onAppear { stats.reloadFromDisk() } .onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in @@ -76,50 +85,18 @@ struct DashboardView: View { .frame(maxWidth: .infinity, alignment: .leading) } - // MARK: - Stats (hero word count, full width but content-height — - // never stretched to match a taller sibling and left with dead air) + // MARK: - Stats (7-day chart + cumulative grid — shared cluster) private var statCluster: some View { - VStack(spacing: Spacing.md) { - StatCard( - title: MacL10n.string("mac.stat.words", language: lang), - value: UsageStatisticsStore.formatCount( - stats.dictationCharacterCount, - language: lang - ), - caption: MacL10n.string("mac.stat.transcribed", language: lang), - systemImage: "text.alignleft", - accent: true, - prominent: true - ) - - HStack(spacing: Spacing.md) { - StatCard( - title: MacL10n.string("mac.stat.dictationTime", language: lang), - value: UsageStatisticsStore.formatDuration( - stats.dictationDurationSeconds, - language: lang - ), - caption: MacL10n.string("mac.stat.cumulativeDuration", language: lang), - systemImage: "waveform" - ) - StatCard( - title: MacL10n.string("mac.stat.translation", language: lang), - value: UsageStatisticsStore.formatCount( - stats.translationCharacterCount, - language: lang - ), - caption: MacL10n.string("mac.stat.cumulativeTranslation", language: lang), - systemImage: "character.bubble" - ) - StatCard( - title: MacL10n.string("mac.stat.dictionary", language: lang), - value: "\(viewModel.dictionaryTermCount)", - caption: MacL10n.string("mac.stat.customTerms", language: lang), - systemImage: "character.book.closed" - ) - } - } + UsageStatsCluster( + layout: .split, + language: lang, + points: stats.last7Days, + dictationCharacterCount: stats.dictationCharacterCount, + dictationDurationSeconds: stats.dictationDurationSeconds, + translationCharacterCount: stats.translationCharacterCount, + dictionaryTermCount: viewModel.dictionaryTermCount + ) } // MARK: - Dictation stage @@ -220,18 +197,29 @@ struct BottomDictationBar: View { } } } label: { - HStack(spacing: 6) { + HStack(spacing: Spacing.sm) { Image(systemName: "translate") + .foregroundStyle(palette.textSecondary) Text(currentTranslationLabel) + .foregroundStyle(palette.textPrimary) .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: Spacing.xs) Image(systemName: "chevron.up.chevron.down") - .font(.system(size: 9, weight: .semibold)) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(palette.textTertiary) } - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) + .font(MacSettingsType.control) .padding(.horizontal, Spacing.sm) - .padding(.vertical, 7) - .background(palette.surfaceElevated, in: Capsule()) + .frame(minHeight: MacMetrics.settingsControlHeight) + .background( + palette.surfaceElevated, + in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + ) + .overlay( + RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) } .menuStyle(.borderlessButton) .fixedSize() diff --git a/OSGKeyboardMac/MacComponents.swift b/OSGKeyboardMac/MacComponents.swift index 6a81bff..39fb1fe 100644 --- a/OSGKeyboardMac/MacComponents.swift +++ b/OSGKeyboardMac/MacComponents.swift @@ -14,8 +14,33 @@ import SwiftUI /// Fixed metrics that keep every desktop surface on the same grid. enum MacMetrics { - /// Uniform max width for trailing text controls (API key / model field). - static let controlWidth: CGFloat = 240 + /// Shared height for credential inputs and icon buttons — matches the iOS + /// settings controls (38). + static let settingsControlHeight: CGFloat = 38 + /// Fixed label column for provider rows: wider than iOS (150) so the label + /// text takes a larger share of the row and the trailing control narrows. + static let settingLabelWidth: CGFloat = 200 + /// Uniform minimum height for every settings row, so rows read as an even + /// list regardless of whether they hold a 38pt control or a single-line + /// label. Content is centered within it; taller rows (status text, progress) + /// grow past it. + static let settingsRowMinHeight: CGFloat = 40 + /// The single vertical rhythm for settings cards: the gap *between* rows AND + /// the padding between a card's top/bottom edge and its first/last row are + /// both this value, so the card breathes evenly. Applied as `VStack(spacing:)` + /// between rows and `.padding(.vertical:)` on the card body. + static let settingsRowGap: CGFloat = Spacing.md + /// Provider menu trigger width (legacy). + static let selectWidth: CGFloat = 200 + /// Text-field max width for provider credential rows (narrower than the + /// old 360pt so long keys truncate instead of wrapping when the window shrinks). + static let fieldWidth: CGFloat = 280 + /// Legacy alias used by older call sites; prefer `fieldWidth`. + static let controlWidth: CGFloat = fieldWidth + /// Horizontal inset inside settings cards — matches History / Dictionary rows. + static let settingsCardInset: CGFloat = Spacing.md + /// Below this row width, provider rows stack label above control. + static let settingsCompactBreakpoint: CGFloat = 520 /// Sidebar width and the horizontal inset shared by brand, nav and footer. static let sidebarWidth: CGFloat = 240 /// Horizontal inset for sidebar chrome (nav rows, footer). The brand logo @@ -46,6 +71,55 @@ enum MacMetrics { static let trafficLightInset: CGFloat = 28 } +// MARK: - Settings typography (mirrors the iOS settings rows) + +/// Type scale that maps 1:1 to the iOS settings controls so the desktop and +/// phone read identically: row label / picker value at body (15), hints at +/// caption2 (11), section header at caption2 (uppercased at the call site). +enum MacSettingsType { + static let sectionTitle = TypeStyle.caption2 + static let rowLabel = TypeStyle.body + static let control = TypeStyle.body + static let controlEmph = TypeStyle.bodyEmph + static let hint = TypeStyle.caption2 + static let button = TypeStyle.body +} + +// MARK: - iOS-style toggle + +/// Pill switch mirroring the iOS settings toggle: accent track when on, neutral +/// when off, 16pt white knob, spring slide. Colours resolve through the active +/// OSG palette so it follows the app theme. +struct MacToggleStyle: ToggleStyle { + @Environment(\.themePalette) private var palette + @Environment(\.colorScheme) private var colorScheme + + func makeBody(configuration: Configuration) -> some View { + Button { + configuration.isOn.toggle() + } label: { + ZStack(alignment: configuration.isOn ? .trailing : .leading) { + Capsule() + .fill(configuration.isOn ? palette.accent : offTrackColor) + Circle() + .fill(Color.white) + .frame(width: 16, height: 16) + .shadow(color: .black.opacity(0.25), radius: 1, y: 1) + .padding(2) + } + .frame(width: 36, height: 20) + .animation(Motion.quick, value: configuration.isOn) + } + .buttonStyle(.plain) + } + + private var offTrackColor: Color { + colorScheme == .dark + ? Color.white.opacity(0.18) + : Color.black.opacity(0.15) + } +} + // MARK: - Liquid Glass private struct MacGlassSurface: ViewModifier { @@ -75,26 +149,296 @@ extension View { // MARK: - Text field styling -/// Text-field chrome aligned with native macOS Form controls: compact -/// height, text-background fill, hairline border. +/// Theme-aware credential field chrome — comfortable tap target, +/// monospaced-friendly body size, and a fill/border that stays clearly +/// distinct from the section card in both appearances. private struct MacFieldStyleModifier: ViewModifier { @Environment(\.themePalette) private var palette + @Environment(\.colorScheme) private var colorScheme + var monospaced: Bool = false func body(content: Content) -> some View { - let shape = RoundedRectangle(cornerRadius: 6, style: .continuous) + // Mirrors the iOS credential field: recessed surface-elevated fill, + // medium radius, hairline border, comfortable 38pt tap target. + let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) content .textFieldStyle(.plain) - .font(TypeStyle.footnote) - .padding(.horizontal, 8) - .frame(height: 22) - .background(Color(nsColor: .textBackgroundColor), in: shape) + .font(monospaced ? TypeStyle.mono : TypeStyle.body) + .foregroundStyle(palette.textPrimary) + .padding(.horizontal, Spacing.sm) + .frame(minHeight: MacMetrics.settingsControlHeight) + .background(fieldFill, in: shape) .overlay(shape.stroke(palette.divider, lineWidth: 0.5)) } + + /// Light mode: the section card reads near-white, so a recessed `surfaceElevated` + /// grey barely differs. Use the system text-input background (true white) so the + /// field reads as an editable well. Dark mode keeps the elevated grey, which sits + /// *lighter* than the card and already stands out. + private var fieldFill: Color { + #if os(macOS) + if colorScheme != .dark { + return Color(nsColor: .textBackgroundColor) + } + #endif + return palette.surfaceElevated + } } extension View { /// Theme-aware text-field styling for the settings inputs. - func macFieldStyle() -> some View { modifier(MacFieldStyleModifier()) } + func macFieldStyle(monospaced: Bool = false) -> some View { + modifier(MacFieldStyleModifier(monospaced: monospaced)) + } +} + +// MARK: - Provider settings chrome + +/// Section header + `MacCard` shell — same outer alignment as History / +/// Dictionary (title and card share `pageHorizontalInset` from the parent). +struct MacSettingsSection: View { + @Environment(\.themePalette) private var palette + + let title: String + var footer: String? = nil + @ViewBuilder var content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: Spacing.sm) { + Text(title) + .font(MacSettingsType.sectionTitle) + .foregroundStyle(palette.textSecondary) + .textCase(.uppercase) + + MacCard(padding: 0) { + VStack(alignment: .leading, spacing: 0) { + content() + } + // Card top/bottom breathe on the same rhythm as the gaps between + // rows (rows own their own inter-row spacing via their container). + .padding(.vertical, MacMetrics.settingsRowGap) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if let footer, !footer.isEmpty { + Text(footer) + .font(TypeStyle.caption) + .foregroundStyle(palette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } +} + +/// iOS-style inline row: title (+ optional subtitle) on the left, control +/// right-aligned. Used by simple picker / toggle rows. +struct MacInlineRow: View { + @Environment(\.themePalette) private var palette + + let title: String + var subtitle: String? = nil + @ViewBuilder var control: () -> Control + + var body: some View { + HStack(alignment: .center, spacing: Spacing.md) { + VStack(alignment: .leading, spacing: Spacing.xxs) { + Text(title) + .font(MacSettingsType.rowLabel) + .foregroundStyle(palette.textPrimary) + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(MacSettingsType.hint) + .foregroundStyle(palette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + Spacer(minLength: Spacing.sm) + control() + } + .padding(.horizontal, MacMetrics.settingsCardInset) + .frame(minHeight: MacMetrics.settingsRowMinHeight) + } +} + +/// Backwards-compatible alias: title + optional subtitle + trailing control. +struct MacFormSubtitleRow: View { + let title: String + var subtitle: String? = nil + @ViewBuilder var control: () -> Control + + var body: some View { + MacInlineRow(title: title, subtitle: subtitle) { + control() + } + } +} + +/// Tappable navigation / action row inside a settings card. +struct MacFormLinkRow: View { + @Environment(\.themePalette) private var palette + + let title: String + var showsChevron: Bool = true + + var body: some View { + HStack(spacing: Spacing.sm) { + Text(title) + .font(MacSettingsType.rowLabel) + .foregroundStyle(palette.textPrimary) + Spacer(minLength: 0) + if showsChevron { + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(palette.textTertiary) + } + } + .padding(.horizontal, MacMetrics.settingsCardInset) + .frame(minHeight: MacMetrics.settingsRowMinHeight) + .contentShape(Rectangle()) + } +} + +/// Single merged card body for LLM / ASR provider configuration sections. +struct MacSettingsProviderCard: View { + @ViewBuilder var content: () -> Content + + var body: some View { + VStack(spacing: MacMetrics.settingsRowGap) { + content() + } + } +} + +// MARK: - Responsive provider setting row + +/// Label + control row for provider cards. Uses a two-column layout at +/// comfortable widths and stacks vertically when horizontal space is tight. +struct MacProviderSettingRow: View { + @Environment(\.themePalette) private var palette + + let title: String + var subtitle: String? = nil + /// Retained for source compatibility; the control now fills its column. + var controlMaxWidth: CGFloat? = nil + /// Cross-axis alignment between the label column and the control. Provider + /// rows keep `.top` (model row grows a status line below its field); single + /// control rows can pass `.center` to vertically center label and control. + var verticalAlignment: VerticalAlignment = .top + @ViewBuilder var content: () -> Content + + var body: some View { + HStack(alignment: verticalAlignment, spacing: Spacing.md) { + label + .frame(width: MacMetrics.settingLabelWidth, alignment: .leading) + content() + .frame(maxWidth: .infinity, alignment: .leading) + } + // Uniform row height (content centered); inter-row spacing is owned by the + // enclosing card, so the row adds no vertical padding of its own. + .frame(maxWidth: .infinity, minHeight: MacMetrics.settingsRowMinHeight, alignment: .leading) + .padding(.horizontal, MacMetrics.settingsCardInset) + } + + private var label: some View { + VStack(alignment: .leading, spacing: Spacing.xxs) { + Text(title) + .font(MacSettingsType.rowLabel) + .foregroundStyle(palette.textPrimary) + .fixedSize(horizontal: false, vertical: true) + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(MacSettingsType.hint) + .foregroundStyle(palette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + // Align the title with the first 38pt control row, not with any + // status/help text that appears below the control. + .frame(minHeight: MacMetrics.settingsControlHeight, alignment: .leading) + } +} + +/// Square icon button matching credential field height. +struct MacSettingsIconButton: View { + @Environment(\.themePalette) private var palette + + let systemName: String + var help: String? = nil + var disabled: Bool = false + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: systemName) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(disabled ? palette.textTertiary : palette.textSecondary) + .frame(width: MacMetrics.settingsControlHeight, height: MacMetrics.settingsControlHeight) + .background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + .disabled(disabled) + .help(help ?? "") + } +} + +/// Compact tool button for validate / fetch-models actions. +struct MacSettingsToolButton: View { + @Environment(\.themePalette) private var palette + + let title: String + /// Background fill. Defaults to the neutral elevated surface. + var fill: Color? = nil + /// Text color. Defaults to primary text (tertiary when disabled). + var foreground: Color? = nil + /// Hairline divider border — drawn for the neutral variant, hidden for + /// colored fills (delete / download) so the fill reads as the button. + var showsBorder: Bool = true + var disabled: Bool = false + let action: () -> Void + + var body: some View { + let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + Button(action: action) { + Text(title) + .font(MacSettingsType.button) + .foregroundStyle(resolvedForeground) + .padding(.horizontal, Spacing.md) + .frame(minHeight: 34) + .background(fill ?? palette.surfaceElevated, in: shape) + .overlay { + if showsBorder { + shape.stroke(palette.divider, lineWidth: 0.5) + } + } + } + .buttonStyle(.plain) + .disabled(disabled) + } + + private var resolvedForeground: Color { + if disabled { return palette.textTertiary } + return foreground ?? palette.textPrimary + } +} + +// MARK: - Legacy setting row + +/// Fixed label column + left-aligned control. Prefer `MacProviderSettingRow` +/// for provider configuration cards. +struct MacSettingRow: View { + let title: String + var controlMaxWidth: CGFloat? = nil + @ViewBuilder var content: () -> Content + + var body: some View { + MacProviderSettingRow(title: title, controlMaxWidth: controlMaxWidth) { + content() + } + } } // MARK: - Page header @@ -169,95 +513,6 @@ struct MacCard: View { } } -// MARK: - Stat tile - -struct StatCard: View { - @Environment(\.themePalette) private var palette - let title: String - let value: String - let caption: String - var systemImage: String? - var accent: Bool = false - /// Hero metric: wide horizontal layout that uses full-card width without - /// stretching to fill dead vertical space — used for the primary word count. - var prominent: Bool = false - - var body: some View { - MacCard(padding: prominent ? Spacing.md : Spacing.md) { - if prominent { - prominentBody - } else { - compactBody - } - } - } - - private var compactBody: some View { - VStack(alignment: .leading, spacing: Spacing.xs) { - HStack { - Text(title.uppercased()) - .font(TypeStyle.caption2) - .tracking(0.6) - .foregroundStyle(palette.textTertiary) - Spacer() - if let systemImage { - Image(systemName: systemImage) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(accent ? palette.accent : palette.textTertiary) - .symbolRenderingMode(.hierarchical) - } - } - Text(value) - .font(TypeStyle.title2) - .foregroundStyle(accent ? palette.accent : palette.textPrimary) - .lineLimit(1) - .minimumScaleFactor(0.7) - .contentTransition(.numericText()) - .animation(Motion.soft, value: value) - Text(caption) - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - /// Wide "hero bar" layout: icon badge + title/caption on the left, the - /// big number anchored right — fills the full card width edge-to-edge - /// instead of a tall card with empty space below a small number. - private var prominentBody: some View { - HStack(spacing: Spacing.md) { - if let systemImage { - ZStack { - Circle() - .fill(palette.accentMuted) - .frame(width: 44, height: 44) - Image(systemName: systemImage) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(palette.accent) - .symbolRenderingMode(.hierarchical) - } - } - VStack(alignment: .leading, spacing: 2) { - Text(title.uppercased()) - .font(TypeStyle.caption2) - .tracking(0.6) - .foregroundStyle(palette.textTertiary) - Text(caption) - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - } - Spacer(minLength: Spacing.md) - Text(value) - .font(.system(size: 34, weight: .bold)) - .foregroundStyle(accent ? palette.accent : palette.textPrimary) - .lineLimit(1) - .minimumScaleFactor(0.6) - .contentTransition(.numericText()) - .animation(Motion.soft, value: value) - } - } -} - // MARK: - Live waveform /// Compact bar visualiser reacting to the input level while recording. @@ -330,6 +585,30 @@ struct MacStatusFooter: View { var body: some View { HStack(spacing: Spacing.sm) { Spacer() + + // Current models, shown just before the engine mode. Label text is + // the model name only; the full "provider · model" lives in the + // hover tooltip to keep the strip quiet. + Label(asrModel.name, systemImage: "waveform") + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 150, alignment: .trailing) + .help(asrModel.tooltip) + .contentTransition(.opacity) + + Text("·") + .foregroundStyle(palette.textTertiary.opacity(0.5)) + + Label(llmModel.name, systemImage: "sparkles") + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 150, alignment: .trailing) + .help(llmModel.tooltip) + .contentTransition(.opacity) + + Text("·") + .foregroundStyle(palette.textTertiary.opacity(0.5)) + Label( viewModel.isCloudMode ? MacL10n.string("mac.mode.cloud", language: lang) @@ -360,6 +639,56 @@ struct MacStatusFooter: View { .padding(.vertical, Spacing.sm) .animation(Motion.quick, value: viewModel.isCloudMode) .animation(Motion.quick, value: viewModel.config.translationTargetLocaleId) + .animation(Motion.quick, value: asrModel.name) + .animation(Motion.quick, value: llmModel.name) + } + + // MARK: - Current model resolution + + /// ASR: cloud shows the configured provider's model (or its default); + /// local shows the selected on-device model's display name. + private var asrModel: (name: String, tooltip: String) { + let config = viewModel.config + if viewModel.isCloudMode { + let providerName = ProviderDisplayName.name(for: config.asrProviderId, language: lang) + let model = config.asrModel.isEmpty + ? CloudASRModelCatalog.defaultModel(for: config.asrProviderId) + : config.asrModel + return (model, "\(providerName) · \(model)") + } + let providerName = MacL10n.string("mac.mode.local", language: lang) + let model = MacLocalASRModelName.displayName(for: MacLocalASRPreferences.selectedModelId, language: lang) + return (model, "\(providerName) · \(model)") + } + + /// LLM: cloud uses the configured provider; local routes through + /// `localModeProviderId` (built-in DeepSeek unless the user supplied a key). + private var llmModel: (name: String, tooltip: String) { + let config = viewModel.config + let providerId = viewModel.isCloudMode ? config.providerId : config.localModeProviderId + let providerName = ProviderDisplayName.name(for: providerId, language: lang) + let model: String + if providerId == config.providerId, !config.model.isEmpty { + model = config.model + } else { + model = LLMProvider.provider(id: providerId).defaultModel + } + return (model, "\(providerName) · \(model)") + } +} + +/// Cached lookup from a local ASR model id to its catalog display name. The +/// bundled catalog is small and immutable, so decoding it once is enough. +enum MacLocalASRModelName { + private static let catalog: LocalASRCatalogDocument? = try? LocalASRModelCatalog.loadBundled() + + static func displayName(for modelId: String, language: AppUILanguage) -> String { + if !modelId.isEmpty, + let catalog, + let model = LocalASRModelCatalog.model(modelId, in: catalog) { + return model.displayName + } + return modelId.isEmpty ? MacL10n.string("mac.mode.local", language: language) : modelId } } diff --git a/OSGKeyboardMac/MacDictationPipeline.swift b/OSGKeyboardMac/MacDictationPipeline.swift index 8cc1671..7f64f04 100644 --- a/OSGKeyboardMac/MacDictationPipeline.swift +++ b/OSGKeyboardMac/MacDictationPipeline.swift @@ -40,7 +40,8 @@ enum MacDictationPipeline { /// Whether the active engine can surface `onPartial` text while recording. static func supportsLivePartials(store: AppGroupStore) -> Bool { if store.engineMode == "local" { return true } - return CloudASRModelCatalog.strategy(for: store.asrProviderId) != .localFallback + let strategy = CloudASRModelCatalog.strategy(for: store.asrProviderId) + return strategy != .localFallback } /// Runs ASR then polish. Polish failures return cleaned raw ASR plus a warning. diff --git a/OSGKeyboardMac/MacDictationViewModel.swift b/OSGKeyboardMac/MacDictationViewModel.swift index 2429067..e7cf11f 100644 --- a/OSGKeyboardMac/MacDictationViewModel.swift +++ b/OSGKeyboardMac/MacDictationViewModel.swift @@ -92,7 +92,9 @@ final class MacDictationViewModel: ObservableObject { self.config = ProviderConfig(defaults: defaults) self.usageStatistics = UsageStatisticsStore(defaults: defaults) self.autoPasteEnabled = defaults.object(forKey: StoredKeys.autoPaste) as? Bool ?? true - self.hotkeyEnabled = defaults.object(forKey: StoredKeys.hotkey) as? Bool ?? true + // Global hotkey is always on — the settings toggle was removed, so any + // previously-stored "off" value is intentionally ignored. + self.hotkeyEnabled = true self.hotkeyTrigger = MacHotkeyTrigger( rawValue: defaults.string(forKey: StoredKeys.hotkeyTrigger) ?? "" ) ?? .rightOption @@ -284,7 +286,7 @@ final class MacDictationViewModel: ObservableObject { do { let result: MacDictationResult if let liveTask { - let capture = await liveTask.value + let capture = await Self.awaitLiveCapture(liveTask) let trimmedLive = capture.raw.trimmingCharacters(in: .whitespacesAndNewlines) if !capture.shouldFallbackToBatch, !trimmedLive.isEmpty { if self.transcript.isEmpty { @@ -362,6 +364,25 @@ final class MacDictationViewModel: ObservableObject { isStreamingPartial = false } + /// Hard timeout so a hung realtime ASR drain cannot leave `isProcessing` stuck. + private static func awaitLiveCapture( + _ task: Task + ) async -> MacLiveASRCaptureResult { + await HardTimeout.value( + seconds: FlowSessionKeys.cloudASRWaitTimeout, + operation: { await task.value }, + onTimeout: { + task.cancel() + return MacLiveASRCaptureResult( + raw: "", + chunkWarning: nil, + localBias: nil, + shouldFallbackToBatch: true + ) + } + ) + } + /// Stops an in-flight prepare, or finishes an active recording. private func cancelOrFinishRecording() { if isRecording { diff --git a/OSGKeyboardMac/MacDictionaryView.swift b/OSGKeyboardMac/MacDictionaryView.swift index 4656ff4..b95b1ff 100644 --- a/OSGKeyboardMac/MacDictionaryView.swift +++ b/OSGKeyboardMac/MacDictionaryView.swift @@ -98,7 +98,7 @@ struct MacDictionaryView: View { private var list: some View { // Full-bleed ScrollView → scrollbar on the detail pane's right edge. ScrollView { - LazyVStack(alignment: .leading, spacing: Spacing.md) { + LazyVStack(alignment: .leading, spacing: Spacing.xl) { if sections.isEmpty { MacCard { Text(MacL10n.string("mac.dict.noMatch", language: lang)) @@ -120,22 +120,18 @@ struct MacDictionaryView: View { private func categorySection( _ section: (category: PersonalDictionary.Entry.Category, items: [PersonalDictionary.Entry]) ) -> some View { - VStack(alignment: .leading, spacing: Spacing.xs) { + VStack(alignment: .leading, spacing: Spacing.sm) { Text(MacL10n.string(section.category.labelKey, language: lang)) - .font(TypeStyle.caption) - .foregroundStyle(palette.textTertiary) + .font(MacSettingsType.sectionTitle) + .foregroundStyle(palette.textSecondary) + .textCase(.uppercase) MacCard(padding: 0) { VStack(spacing: 0) { - ForEach(Array(section.items.enumerated()), id: \.element.id) { index, entry in + ForEach(section.items, id: \.id) { entry in row(entry) .padding(.horizontal, Spacing.md) .padding(.vertical, Spacing.sm) - if index < section.items.count - 1 { - // Full-bleed like macOS list rows (not iOS inset separators). - Divider() - .overlay(palette.divider) - } } } } diff --git a/OSGKeyboardMac/MacHistoryView.swift b/OSGKeyboardMac/MacHistoryView.swift index bbb5dbe..b226e2d 100644 --- a/OSGKeyboardMac/MacHistoryView.swift +++ b/OSGKeyboardMac/MacHistoryView.swift @@ -83,7 +83,7 @@ struct MacHistoryView: View { // Full-bleed ScrollView → scrollbar on the detail pane's right edge. // Horizontal inset lives on the content so cards align with the title. ScrollView { - LazyVStack(alignment: .leading, spacing: Spacing.md) { + LazyVStack(alignment: .leading, spacing: Spacing.xl) { ForEach(historyStore.groupedByDay, id: \.day) { group in daySection(group) } @@ -94,22 +94,18 @@ struct MacHistoryView: View { } private func daySection(_ group: (day: Date, items: [SpeechHistoryEntry])) -> some View { - VStack(alignment: .leading, spacing: Spacing.xs) { + VStack(alignment: .leading, spacing: Spacing.sm) { Text(Self.dayFormatter.string(from: group.day)) - .font(TypeStyle.caption) - .foregroundStyle(palette.textTertiary) + .font(MacSettingsType.sectionTitle) + .foregroundStyle(palette.textSecondary) + .textCase(.uppercase) MacCard(padding: 0) { VStack(spacing: 0) { - ForEach(Array(group.items.enumerated()), id: \.element.id) { index, entry in + ForEach(group.items, id: \.id) { entry in row(entry) .padding(.horizontal, Spacing.md) .padding(.vertical, Spacing.sm) - if index < group.items.count - 1 { - // Full-bleed like macOS list rows (not iOS inset separators). - Divider() - .overlay(palette.divider) - } } } } diff --git a/OSGKeyboardMac/MacICloudSyncRows.swift b/OSGKeyboardMac/MacICloudSyncRows.swift index c6057dd..b5f269d 100644 --- a/OSGKeyboardMac/MacICloudSyncRows.swift +++ b/OSGKeyboardMac/MacICloudSyncRows.swift @@ -1,10 +1,8 @@ // MacICloudSyncRows.swift // OSGKeyboard · Mac // -// iCloud sync toggles for settings and personal dictionary — same KVS -// keys and merge rules as the iOS settings page. Rendered as native Form -// rows (Toggle + optional action / error) so they sit inside a grouped -// `Form` section and match System Settings exactly. +// iCloud sync toggle for settings, history, API keys, and personal +// dictionary — same KVS keys and merge rules as the iOS settings page. import SwiftUI @@ -19,38 +17,51 @@ struct MacSettingsICloudSyncRow: View { @State private var isSyncingNow = false var body: some View { - Toggle(isOn: toggleBinding) { - VStack(alignment: .leading, spacing: 2) { - Text(MacL10n.string("mac.sync.settingsTitle", language: language)) + MacProviderSettingRow( + title: MacL10n.string("mac.sync.settingsTitle", language: language), + verticalAlignment: .center + ) { + HStack(alignment: .center, spacing: Spacing.sm) { + Toggle("", isOn: toggleBinding) + .labelsHidden() + .toggleStyle(MacToggleStyle()) + .disabled(isApplyingToggle) Text(MacL10n.string("mac.sync.settingsSubtitle", language: language)) - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) + .font(TypeStyle.caption2) + .foregroundStyle(palette.textTertiary) .fixedSize(horizontal: false, vertical: true) } } - .tint(palette.accent) - .disabled(isApplyingToggle) .onAppear { reloadFromStore() } .onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in reloadFromStore() } + .onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in + reloadFromStore() + } if isEnabled { - Button { - syncNow() - } label: { - HStack(spacing: Spacing.xs) { - if isSyncingNow { ProgressView().controlSize(.small) } - Text(MacL10n.string("mac.sync.syncNow", language: language)) + HStack(spacing: Spacing.xs) { + MacSettingsToolButton( + title: MacL10n.string("mac.sync.syncNow", language: language), + disabled: isSyncingNow || isApplyingToggle + ) { + syncNow() } + if isSyncingNow { ProgressView().controlSize(.small) } + Spacer(minLength: 0) } - .disabled(isSyncingNow || isApplyingToggle) + .padding(.horizontal, MacMetrics.settingsCardInset) + .padding(.bottom, Spacing.sm) } if let syncErrorMessage { Text(syncErrorMessage) - .font(TypeStyle.caption) + .font(MacSettingsType.hint) .foregroundStyle(palette.danger) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, MacMetrics.settingsCardInset) + .padding(.bottom, Spacing.sm) } } @@ -74,6 +85,19 @@ struct MacSettingsICloudSyncRow: View { Task { do { try await MacICloudSyncBootstrap.settingsSync.enableSync() + do { + try await MacICloudSyncBootstrap.dictionarySync.enableSync() + } catch let error as PersonalDictionaryCloudSyncError { + MacICloudSyncBootstrap.settingsSync.disableSync() + isEnabled = false + if case .payloadTooLarge = error { + syncErrorMessage = MacL10n.string("mac.sync.error.dictTooLarge", language: language) + } else { + syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language) + } + isApplyingToggle = false + return + } reloadFromStore() } catch { isEnabled = false @@ -102,79 +126,3 @@ struct MacSettingsICloudSyncRow: View { } } } - -struct MacDictionaryICloudSyncRow: View { - let defaults: UserDefaults - let language: AppUILanguage - - @Environment(\.themePalette) private var palette - @State private var isEnabled = false - @State private var syncErrorMessage: String? - @State private var isApplyingToggle = false - - var body: some View { - Toggle(isOn: toggleBinding) { - VStack(alignment: .leading, spacing: 2) { - Text(MacL10n.string("mac.sync.dictTitle", language: language)) - Text(MacL10n.string("mac.sync.dictSubtitle", language: language)) - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } - } - .tint(palette.accent) - .disabled(isApplyingToggle) - .onAppear { reloadFromStore() } - .onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in - reloadFromStore() - } - - if let syncErrorMessage { - Text(syncErrorMessage) - .font(TypeStyle.caption) - .foregroundStyle(palette.danger) - } - } - - private var toggleBinding: Binding { - Binding( - get: { isEnabled }, - set: { newValue in - guard newValue != isEnabled else { return } - newValue ? enableSync() : disableSync() - } - ) - } - - private func reloadFromStore() { - isEnabled = AppGroupStore(defaults: defaults).personalDictionaryICloudSyncEnabled - } - - private func enableSync() { - isApplyingToggle = true - syncErrorMessage = nil - Task { - do { - try await MacICloudSyncBootstrap.dictionarySync.enableSync() - reloadFromStore() - } catch let error as PersonalDictionaryCloudSyncError { - isEnabled = false - if case .payloadTooLarge = error { - syncErrorMessage = MacL10n.string("mac.sync.error.dictTooLarge", language: language) - } else { - syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language) - } - } catch { - isEnabled = false - syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language) - } - isApplyingToggle = false - } - } - - private func disableSync() { - MacICloudSyncBootstrap.dictionarySync.disableSync() - isEnabled = false - syncErrorMessage = nil - } -} diff --git a/OSGKeyboardMac/MacLocalASRModelSettingsView.swift b/OSGKeyboardMac/MacLocalASRModelSettingsView.swift index c8bf8e4..72f5b17 100644 --- a/OSGKeyboardMac/MacLocalASRModelSettingsView.swift +++ b/OSGKeyboardMac/MacLocalASRModelSettingsView.swift @@ -198,6 +198,9 @@ struct MacLocalASRModelSettingsView: View { @ObservedObject var viewModel: MacDictationViewModel @StateObject private var modelVM = MacLocalASRModelSettingsViewModel() @Environment(\.themePalette) private var palette + /// Only one model installs at a time, so a single hover flag drives the + /// active download ring's pause/resume affordance. + @State private var isHoveringProgress = false private var lang: AppUILanguage { viewModel.config.uiLanguage } @@ -217,42 +220,54 @@ struct MacLocalASRModelSettingsView: View { } private func modelPickerSection(catalog: LocalASRCatalogDocument) -> some View { - Section { - if let runtime = modelVM.currentRuntime(in: catalog) { - LabeledContent(runtime.displayName) { - Text( - modelVM.isRuntimeInstalled(runtime) - ? MacL10n.string("mac.localASR.installed", language: lang) - : MacL10n.string("mac.localASR.notInstalled", language: lang) - ) + MacSettingsSection(title: MacL10n.string("mac.localASR.models", language: lang)) { + VStack(spacing: MacMetrics.settingsRowGap) { + if let runtime = modelVM.currentRuntime(in: catalog) { + MacFormSubtitleRow(title: runtime.displayName) { + Text( + modelVM.isRuntimeInstalled(runtime) + ? MacL10n.string("mac.localASR.installed", language: lang) + : MacL10n.string("mac.localASR.notInstalled", language: lang) + ) + .font(TypeStyle.body) + .foregroundStyle(palette.textSecondary) + } } - } - ForEach(catalog.models) { model in - modelRow(model) - } + ForEach(Array(catalog.models.enumerated()), id: \.element.id) { _, model in + modelRow(model) + .frame(minHeight: MacMetrics.settingsRowMinHeight) + .padding(.horizontal, MacMetrics.settingsCardInset) + } - if modelVM.isInstalling, - modelVM.installProgress.phase == .extracting - || modelVM.installProgress.phase == .validating - || modelVM.installProgress.phase == .finalizing { - ProgressView(value: modelVM.installProgress.fraction) { - Text(modelVM.progressLabel(for: modelVM.installProgress, language: lang)) + if modelVM.isInstalling, + modelVM.installProgress.phase == .extracting + || modelVM.installProgress.phase == .validating + || modelVM.installProgress.phase == .finalizing { + ProgressView(value: modelVM.installProgress.fraction) { + Text(modelVM.progressLabel(for: modelVM.installProgress, language: lang)) + .font(TypeStyle.caption) + } + .frame(minHeight: MacMetrics.settingsRowMinHeight) + .padding(.horizontal, MacMetrics.settingsCardInset) + } + + if !modelVM.statusMessage.isEmpty { + Text(modelVM.statusMessage) .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + .frame(minHeight: MacMetrics.settingsRowMinHeight) + .padding(.horizontal, MacMetrics.settingsCardInset) } - } - if !modelVM.statusMessage.isEmpty { - Text(modelVM.statusMessage) - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) + HStack(spacing: 0) { + MacSettingsToolButton(title: MacL10n.string("mac.localASR.openStorage", language: lang)) { + modelVM.revealStorageRoot() + } + Spacer(minLength: 0) + } + .padding(.horizontal, MacMetrics.settingsCardInset) } - - Button(MacL10n.string("mac.localASR.openStorage", language: lang)) { - modelVM.revealStorageRoot() - } - } header: { - Text(MacL10n.string("mac.localASR.models", language: lang)) } } @@ -305,7 +320,7 @@ struct MacLocalASRModelSettingsView: View { .animation(Motion.soft, value: installed) } } - .padding(.vertical, 2) + .padding(.vertical, 0) } private func modelBadge(_ title: String, emphasized: Bool) -> some View { @@ -329,49 +344,39 @@ struct MacLocalASRModelSettingsView: View { installing: Bool ) -> some View { if installing { - HStack(spacing: Spacing.sm) { - circularInstallProgress(for: model) - if modelVM.installProgress.phase == .downloading - || modelVM.installProgress.phase == .paused { - Button { - if modelVM.isDownloadPaused { - modelVM.resumeDownload() - } else { - modelVM.pauseDownload() - } - } label: { - Image(systemName: modelVM.isDownloadPaused ? "play.fill" : "pause.fill") - .font(.system(size: 12, weight: .semibold)) - .frame(width: 28, height: 28) - } - .buttonStyle(.bordered) - .controlSize(.small) - .help( - modelVM.isDownloadPaused - ? MacL10n.string("mac.localASR.resume", language: lang) - : MacL10n.string("mac.localASR.pause", language: lang) - ) - } - } + installProgressControl(for: model) + .animation(Motion.quick, value: isHoveringProgress) + .animation(Motion.quick, value: modelVM.isDownloadPaused) } else if installed { - Button(MacL10n.string("mac.localASR.delete", language: lang), role: .destructive) { + MacSettingsToolButton( + title: MacL10n.string("mac.localASR.delete", language: lang), + fill: palette.danger.opacity(0.15), + foreground: palette.danger, + showsBorder: false + ) { modelVM.deleteModel(model) } - .buttonStyle(.bordered) - .controlSize(.small) - .tint(palette.danger) } else { - Button(MacL10n.string("mac.localASR.download", language: lang)) { + MacSettingsToolButton( + title: MacL10n.string("mac.localASR.download", language: lang), + fill: palette.accent, + foreground: .white, + showsBorder: false + ) { modelVM.installModel(model) } - .buttonStyle(.borderedProminent) - .controlSize(.small) } } - private func circularInstallProgress(for model: LocalASRModelDefinition) -> some View { + /// Single download ring. Shows the percentage while downloading; on hover it + /// reveals a clickable pause icon, and once paused it keeps a resume (play) + /// icon so the state stays clear without a second button. + private func installProgressControl(for model: LocalASRModelDefinition) -> some View { + let phase = modelVM.installProgress.phase + let pausable = phase == .downloading || phase == .paused + let paused = modelVM.isDownloadPaused let fraction: Double = { - if modelVM.installProgress.phase == .downloading || modelVM.installProgress.phase == .paused, + if pausable, let received = modelVM.installProgress.bytesReceived, let total = modelVM.installProgress.bytesTotal, total > 0 { @@ -379,6 +384,7 @@ struct MacLocalASRModelSettingsView: View { } return modelVM.installProgress.fraction }() + return ZStack { Circle() .stroke(palette.textTertiary.opacity(0.25), lineWidth: 3) @@ -387,20 +393,46 @@ struct MacLocalASRModelSettingsView: View { .stroke(palette.accent, style: StrokeStyle(lineWidth: 3, lineCap: .round)) .rotationEffect(.degrees(-90)) .animation(Motion.instant, value: fraction) - if modelVM.installProgress.phase == .paused { - Image(systemName: "pause.fill") - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(palette.textSecondary) - } else { - Text("\(Int(fraction * 100))%") - .font(.system(size: 9, weight: .medium, design: .rounded)) - .foregroundStyle(palette.textSecondary) - } + + progressCenter(pausable: pausable, paused: paused, fraction: fraction) } .frame(width: 36, height: 36) + .contentShape(Circle()) + .onHover { hovering in + guard pausable else { return } + isHoveringProgress = hovering + } + .onTapGesture { + guard pausable else { return } + if paused { modelVM.resumeDownload() } else { modelVM.pauseDownload() } + } + .help( + pausable + ? (paused + ? MacL10n.string("mac.localASR.resume", language: lang) + : MacL10n.string("mac.localASR.pause", language: lang)) + : modelVM.progressLabel(for: modelVM.installProgress, language: lang) + ) .accessibilityLabel(modelVM.progressLabel(for: modelVM.installProgress, language: lang)) } + @ViewBuilder + private func progressCenter(pausable: Bool, paused: Bool, fraction: Double) -> some View { + if pausable, paused { + Image(systemName: "play.fill") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(palette.accent) + } else if pausable, isHoveringProgress { + Image(systemName: "pause.fill") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(palette.textSecondary) + } else { + Text("\(Int(fraction * 100))%") + .font(.system(size: 9, weight: .medium, design: .rounded)) + .foregroundStyle(palette.textSecondary) + } + } + private func modelSubtitle(_ model: LocalASRModelDefinition, installed: Bool) -> String { let size = modelVM.formattedSize(model.sizeBytes) if let usage = modelVM.installedDiskUsage(model) { diff --git a/OSGKeyboardMac/MacOnboardingView.swift b/OSGKeyboardMac/MacOnboardingView.swift index 32c3362..354f1d0 100644 --- a/OSGKeyboardMac/MacOnboardingView.swift +++ b/OSGKeyboardMac/MacOnboardingView.swift @@ -422,12 +422,15 @@ struct MacOnboardingView: View { private var cloudAPIFields: some View { VStack(alignment: .leading, spacing: Spacing.md) { - Picker(MacL10n.string("mac.settings.service", language: lang), selection: providerBinding) { - ForEach(viewModel.selectableProviders) { provider in - Text(provider.name).tag(provider.id) + MacInlinePicker( + selection: providerBinding, + options: viewModel.selectableProviders.map { + MacInlinePickerOption( + value: $0.id, + label: ProviderDisplayName.name(for: $0.id, language: lang) + ) } - } - .labelsHidden() + ) .frame(maxWidth: .infinity, alignment: .leading) SecureField(text: $viewModel.config.apiKey, prompt: Text(verbatim: "sk-...")) { diff --git a/OSGKeyboardMac/MacQwen3ASREngine.swift b/OSGKeyboardMac/MacQwen3ASREngine.swift deleted file mode 100644 index e06fc3b..0000000 --- a/OSGKeyboardMac/MacQwen3ASREngine.swift +++ /dev/null @@ -1,95 +0,0 @@ -// MacQwen3ASREngine.swift -// OSGKeyboard · Mac -// -// Singleton actor that loads, warms up, and runs Qwen3-ASR via mlx-swift-asr. -// Model load + Metal JIT warmup take several seconds — call `prepareIfNeeded` -// at launch so the first dictation is fast. - -import Foundation -import MLXASR - -/// Lifecycle of the on-disk MLX model inside the app process. -enum MacQwen3EnginePhase: Sendable, Equatable { - case idle - case loading - case ready - case failed(String) -} - -actor MacQwen3ASREngine { - static let shared = MacQwen3ASREngine() - - private var stt: Qwen3ASRSTT? - private var loadedModelPath: String? - private(set) var phase: MacQwen3EnginePhase = .idle - - private init() {} - - /// Load and warm up the model when the path changes or nothing is loaded yet. - func prepareIfNeeded(modelPath: String) async throws { - if loadedModelPath == modelPath, stt != nil, phase == .ready { return } - - phase = .loading - stt = nil - loadedModelPath = nil - - let directory = URL(fileURLWithPath: modelPath, isDirectory: true) - do { - let instance = try await Qwen3ASRSTT.loadWithWarmup(from: directory) - stt = instance - loadedModelPath = modelPath - phase = .ready - } catch { - let detail = error.localizedDescription - phase = .failed(detail) - throw MacLocalASRError.qwen3LoadFailed(detail) - } - } - - /// Transcribe mono 16 kHz float PCM. Ensures the model is loaded first. - func transcribe( - samples: [Float], - language: String?, - modelPath: String, - context: String? = nil - ) async throws -> String { - try await prepareIfNeeded(modelPath: modelPath) - guard let stt else { - throw MacLocalASRError.qwen3LoadFailed("Engine not initialized") - } - - let result = try await stt.transcribe(audio: samples, language: language, context: context) - let text = result.text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { - throw MacLocalASRError.emptyTranscript - } - return text - } - - /// Drop cached weights (e.g. after the user changes the model folder). - func unload() { - stt = nil - loadedModelPath = nil - phase = .idle - } -} - -enum MacQwen3LanguageHint { - /// Map persisted BCP-47 locale ids to Qwen3 prompt language names. - /// Returns `nil` for auto-detect. - static func from(locale: Locale) -> String? { - let raw = locale.identifier.lowercased() - if raw.isEmpty || raw == "auto" { return nil } - if raw.hasPrefix("zh") { return "Chinese" } - if raw.hasPrefix("en") { return "English" } - if raw.hasPrefix("ja") { return "Japanese" } - if raw.hasPrefix("ko") { return "Korean" } - if raw.hasPrefix("fr") { return "French" } - if raw.hasPrefix("de") { return "German" } - if raw.hasPrefix("es") { return "Spanish" } - if raw.hasPrefix("pt") { return "Portuguese" } - if raw.hasPrefix("ru") { return "Russian" } - if raw.hasPrefix("ar") { return "Arabic" } - return nil - } -} diff --git a/OSGKeyboardMac/MacQwen3LocalASR.swift b/OSGKeyboardMac/MacQwen3LocalASR.swift deleted file mode 100644 index 4088df3..0000000 --- a/OSGKeyboardMac/MacQwen3LocalASR.swift +++ /dev/null @@ -1,58 +0,0 @@ -// MacQwen3LocalASR.swift -// OSGKeyboard · Mac -// -// Qwen3-ASR-1.7B (MLX) via mlx-swift-asr. Expects a converted model directory -// containing config.json, model.safetensors, and tokenizer files. - -import Foundation - -enum MacQwen3LocalASR { - /// Transcribe with Qwen3-ASR MLX weights at `modelPath`. - static func transcribe( - samples: [Float], - sampleRate: Int, - locale: Locale, - modelPath: String, - bias: LocalASRBiasPayload? = nil - ) async throws -> String { - guard modelDirectoryIsInstalled(at: modelPath) else { - throw MacLocalASRError.qwen3ModelMissing - } - guard sampleRate == 16_000 else { - throw MacLocalASRError.qwen3InferenceFailed( - "Qwen3-ASR expects 16 kHz audio (got \(sampleRate) Hz)" - ) - } - - let language = MacQwen3LanguageHint.from(locale: locale) - let context = bias?.promptBias?.trimmingCharacters(in: .whitespacesAndNewlines) - let promptContext = (context?.isEmpty == false) ? context : nil - do { - return try await MacQwen3ASREngine.shared.transcribe( - samples: samples, - language: language, - modelPath: modelPath, - context: promptContext - ) - } catch let error as MacLocalASRError { - throw error - } catch { - throw MacLocalASRError.qwen3InferenceFailed(error.localizedDescription) - } - } - - private static func modelDirectoryIsInstalled(at path: String) -> Bool { - var isDir: ObjCBool = false - guard FileManager.default.fileExists(atPath: path, isDirectory: &isDir), isDir.boolValue else { - return false - } - let fm = FileManager.default - let config = (path as NSString).appendingPathComponent("config.json") - let weights = (path as NSString).appendingPathComponent("model.safetensors") - guard fm.fileExists(atPath: config), fm.fileExists(atPath: weights) else { - return false - } - let names = (try? fm.contentsOfDirectory(atPath: path)) ?? [] - return names.contains("vocab.json") && names.contains("merges.txt") - } -} diff --git a/OSGKeyboardMac/MacRootView.swift b/OSGKeyboardMac/MacRootView.swift index fdef0aa..382242d 100644 --- a/OSGKeyboardMac/MacRootView.swift +++ b/OSGKeyboardMac/MacRootView.swift @@ -38,7 +38,7 @@ struct MacRootView: View { private var sidebar: some View { VStack(spacing: 0) { brandHeader - VStack(spacing: 4) { + VStack(spacing: 6) { ForEach(MacSection.allCases) { section in MacSidebarRow( section: section, @@ -70,8 +70,8 @@ struct MacRootView: View { } .padding(.leading, MacMetrics.sidebarContentInset) .padding(.trailing, MacMetrics.sidebarInset) - .padding(.top, Spacing.lg) - .padding(.bottom, Spacing.md) + .padding(.top, 30) + .padding(.bottom, 30) } private var devicesFooter: some View { @@ -127,10 +127,10 @@ private struct MacSidebarRow: View { .foregroundStyle(isSelected ? palette.accent : palette.textPrimary) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, Spacing.sm) - .padding(.vertical, 7) + .padding(.vertical, 9) .background( rowBackground, - in: RoundedRectangle(cornerRadius: 7, style: .continuous) + in: RoundedRectangle(cornerRadius: 8, style: .continuous) ) .contentShape(Rectangle()) } diff --git a/OSGKeyboardMac/MacSettingsComponents.swift b/OSGKeyboardMac/MacSettingsComponents.swift new file mode 100644 index 0000000..740a7bc --- /dev/null +++ b/OSGKeyboardMac/MacSettingsComponents.swift @@ -0,0 +1,543 @@ +// MacSettingsComponents.swift +// OSGKeyboard · Mac +// +// Provider configuration rows for the Settings Form — merged card layout, +// responsive label/control rows, and theme-aware credential chrome. + +import SwiftUI +#if os(macOS) +import AppKit +#endif + +// MARK: - Width-locked inline picker + +/// Option for `MacInlinePicker`: a hashable value plus its display label. +struct MacInlinePickerOption: Identifiable { + let value: Value + let label: String + var id: Value { value } +} + +/// Boxed dropdown trigger that mirrors `.macFieldStyle()` chrome so every picker +/// reads as an editable field: the selected value on the left, a trailing +/// up/down chevron inside the *same* well. Holds a `minWidth` field footprint and +/// expands up to its column when the caller offers width (provider rows). +private struct MacPickerFieldBox: View { + @Environment(\.themePalette) private var palette + @Environment(\.colorScheme) private var colorScheme + + let text: String + + var body: some View { + // Same shape/fill/border rules as `MacFieldStyleModifier` so the dropdown + // sits flush with the credential text fields around it. + let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + HStack(spacing: Spacing.sm) { + Text(text) + .font(MacSettingsType.control) + .foregroundStyle(palette.textPrimary) + .lineLimit(1) + .truncationMode(.tail) + + Spacer(minLength: Spacing.xs) + + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(palette.textTertiary) + } + .padding(.horizontal, Spacing.sm) + // `minWidth` gives short values (e.g. "深色") a proper field footprint; + // `maxWidth: .infinity` lets fixed-label rows (provider picker) fill their + // column, while `.fixedSize()` callers (inline rows) hug to the minimum. + .frame(minWidth: 132, maxWidth: .infinity, minHeight: MacMetrics.settingsControlHeight, alignment: .leading) + .background(fieldFill, in: shape) + .overlay(shape.stroke(palette.divider, lineWidth: 0.5)) + .contentShape(shape) + } + + /// Light mode uses the true text-input white so the box reads as an editable + /// well; dark mode keeps the elevated grey (mirrors `MacFieldStyleModifier`). + private var fieldFill: Color { + #if os(macOS) + if colorScheme != .dark { + return Color(nsColor: .textBackgroundColor) + } + #endif + return palette.surfaceElevated + } +} + +/// Dropdown that renders as an editable-looking field (see `MacPickerFieldBox`). +/// A `.menu` `Picker` renders as a native pop-up that ignores `.frame` and drops +/// custom labels; `Menu` instead renders its `label:` as the real control. Kept at +/// `.fixedSize()` so it hugs its content and is right-aligned by the enclosing +/// row's `Spacer`, matching the toggles/status text that share `MacInlineRow`. +struct MacInlinePicker: View { + @Binding var selection: Value + let options: [MacInlinePickerOption] + /// When true the boxed field fills its column and is left-aligned — matches + /// the two-column provider rows (see `MacProviderPickerRow`). When false it + /// hugs its content so the enclosing row's `Spacer` can right-align it. + var fillsWidth: Bool = false + + var body: some View { + if fillsWidth { + menuButton + .frame(maxWidth: .infinity, alignment: .leading) + } else { + menuButton + .fixedSize() + } + } + + private var menuButton: some View { + Menu { + ForEach(options) { option in + Button { + selection = option.value + } label: { + if option.value == selection { + Label(option.label, systemImage: "checkmark") + } else { + Text(option.label) + } + } + } + } label: { + MacPickerFieldBox(text: selectedLabel) + } + // `.button` menu style + `.plain` button style renders the custom + // `MacPickerFieldBox` label verbatim; `.borderlessButton` would instead + // draw macOS's own borderless pop-up chrome (accent chevrons, no box). + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + } + + private var selectedLabel: String { + options.first { $0.value == selection }?.label ?? "—" + } +} + +// MARK: - Provider picker + +struct MacProviderPickerRow: View { + let title: String + let providers: [LLMProvider] + @Binding var selection: String + + var body: some View { + MacProviderSettingRow(title: title) { + Menu { + ForEach(providers) { provider in + Button { + selection = provider.id + } label: { + let name = ProviderDisplayName.name(for: provider.id) + if provider.id == selection { + Label(name, systemImage: "checkmark") + } else { + Text(name) + } + } + } + } label: { + MacPickerFieldBox(text: selectedName) + } + // See `MacInlinePicker`: `.button` + `.plain` keeps our boxed label + // instead of macOS's borderless pop-up chrome. + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var selectedName: String { + let id = providers.first { $0.id == selection }?.id ?? selection + return ProviderDisplayName.name(for: id) + } +} + +// MARK: - Credential field + +struct MacCredentialField: View { + let title: String + let placeholder: String + @Binding var text: String + var isSecret: Bool = false + var isMonospaced: Bool = true + var defaultValue: String? + var trailing: AnyView? + + @State private var revealed = false + + var body: some View { + MacProviderSettingRow(title: title) { + HStack(spacing: Spacing.xs) { + input + .frame(maxWidth: .infinity, alignment: .leading) + + if let defaultValue, text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + MacSettingsIconButton(systemName: "checkmark", help: "Fill default") { + text = defaultValue + } + } + + if let trailing { + trailing + } + + if isSecret { + MacSettingsIconButton( + systemName: revealed ? "eye.slash" : "eye", + help: revealed ? "Hide" : "Show" + ) { + revealed.toggle() + } + } + + MacSettingsIconButton(systemName: "doc.on.doc", help: "Copy", disabled: text.isEmpty) { + #if os(macOS) + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + #endif + } + } + } + } + + @ViewBuilder + private var input: some View { + Group { + if isSecret && !revealed { + SecureField(text: $text, prompt: Text(verbatim: placeholder)) { + Text(title) + } + } else { + TextField(text: $text, prompt: Text(verbatim: placeholder)) { + Text(title) + } + } + } + .labelsHidden() + .autocorrectionDisabled(true) + .lineLimit(1) + .truncationMode(.middle) + .macFieldStyle(monospaced: isMonospaced) + } +} + +// MARK: - Thinking toggle + +struct MacProviderThinkingRow: View { + @Environment(\.themePalette) private var palette + + let title: String + var subtitle: String? = nil + @Binding var isOn: Bool + + var body: some View { + MacProviderSettingRow(title: title, verticalAlignment: .center) { + HStack(alignment: .center, spacing: Spacing.sm) { + Toggle("", isOn: $isOn) + .labelsHidden() + .toggleStyle(MacToggleStyle()) + .accessibilityLabel(title) + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(TypeStyle.caption2) + .foregroundStyle(palette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } +} + +// MARK: - Model picker (editable field + fetch icon) + +private struct MacModelComboFieldWidthKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +struct MacProviderModelRow: View { + @Environment(\.themePalette) private var palette + @Environment(\.colorScheme) private var colorScheme + + let title: String + let placeholder: String + @Binding var model: String + let apiKey: String + let fetchModels: () async throws -> [String] + let language: AppUILanguage + + @State private var models: [String] = [] + @State private var isRunning = false + @State private var message: String? + @State private var failed = false + @State private var isDropdownOpen = false + @State private var fieldWidth: CGFloat = 0 + + private let chevronWidth: CGFloat = 28 + private let dropdownMaxHeight: CGFloat = 240 + + private var trimmedAPIKey: String { + apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Match `MacFieldStyleModifier` fill so the combo well sits flush with + /// neighboring credential fields. + private var fieldFill: Color { + #if os(macOS) + if colorScheme != .dark { + return Color(nsColor: .textBackgroundColor) + } + #endif + return palette.surfaceElevated + } + + var body: some View { + MacProviderSettingRow(title: title) { + VStack(alignment: .leading, spacing: Spacing.xs) { + HStack(spacing: Spacing.xs) { + comboField + + MacSettingsIconButton( + systemName: "arrow.triangle.2.circlepath", + help: MacL10n.string("mac.settings.fetchModels", language: language), + disabled: isRunning || trimmedAPIKey.isEmpty + ) { + isDropdownOpen = false + Task { await runFetchModels() } + } + + if isRunning { + ProgressView() + .controlSize(.small) + } + } + + statusMessage + } + } + } + + /// Editable model id + trailing chevron. Model list is a true popover so the + /// settings row height stays fixed (not an in-flow panel that grows the card). + private var comboField: some View { + let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + return ZStack(alignment: .trailing) { + TextField(text: $model, prompt: Text(verbatim: placeholder)) { + Text(title) + } + .labelsHidden() + .textFieldStyle(.plain) + .autocorrectionDisabled(true) + .font(TypeStyle.mono) + .foregroundStyle(palette.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + .padding(.leading, Spacing.sm) + .padding(.trailing, chevronWidth + Spacing.sm) + .frame(maxWidth: .infinity, minHeight: MacMetrics.settingsControlHeight, alignment: .leading) + .background(fieldFill, in: shape) + .overlay(shape.stroke(palette.divider, lineWidth: 0.5)) + + Button { + isDropdownOpen.toggle() + } label: { + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(palette.textTertiary) + .frame(width: chevronWidth, height: MacMetrics.settingsControlHeight) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.trailing, Spacing.sm) + .help(MacL10n.string("mac.settings.selectModel", language: language)) + .accessibilityLabel(MacL10n.string("mac.settings.selectModel", language: language)) + } + .background( + GeometryReader { geo in + Color.clear.preference(key: MacModelComboFieldWidthKey.self, value: geo.size.width) + } + ) + .onPreferenceChange(MacModelComboFieldWidthKey.self) { fieldWidth = $0 } + .popover(isPresented: $isDropdownOpen, attachmentAnchor: .rect(.bounds), arrowEdge: .bottom) { + dropdownPanel + .frame(width: max(fieldWidth, 180)) + .padding(Spacing.xs) + } + } + + private var dropdownPanel: some View { + Group { + if models.isEmpty { + Text(MacL10n.string("mac.settings.modelsEmptyHint", language: language)) + .font(MacSettingsType.hint) + .foregroundStyle(palette.textTertiary) + .padding(.horizontal, Spacing.sm) + .padding(.vertical, Spacing.sm) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + ScrollView { + VStack(alignment: .leading, spacing: Spacing.xxs) { + ForEach(models, id: \.self) { modelId in + dropdownRow(modelId) + } + } + } + .frame(maxHeight: dropdownMaxHeight) + } + } + } + + private func dropdownRow(_ modelId: String) -> some View { + let isSelected = modelId == model + return Button { + model = modelId + message = MacL10n.format( + "mac.settings.modelSelected", + language: language, + modelId + ) + failed = false + isDropdownOpen = false + } label: { + HStack(spacing: Spacing.sm) { + Text(modelId) + .font(TypeStyle.mono) + .foregroundStyle(isSelected ? palette.accent : palette.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(palette.accent) + } + } + .padding(.horizontal, Spacing.sm) + .padding(.vertical, Spacing.sm) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: Radius.small, style: .continuous) + .fill(isSelected ? palette.accentMuted : Color.clear) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private var statusMessage: some View { + if let message { + Text(message) + .font(MacSettingsType.hint) + .foregroundStyle(failed ? palette.danger : palette.accent) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + } + } + + @MainActor + private func runFetchModels() async { + guard !trimmedAPIKey.isEmpty else { + failed = true + message = SharedL10n.string("providerTools.error.missingAPIKey", language: language) + return + } + + isRunning = true + failed = false + defer { isRunning = false } + + let outcome = await ProviderToolRunner.runFetchModels( + runningMessage: MacL10n.string("mac.settings.loadingModels", language: language), + loadedMessage: { MacL10n.format("mac.settings.modelsLoaded", language: language, $0) }, + emptyMessage: SharedL10n.string("providerTools.error.empty", language: language), + currentModel: model, + fetchModels: fetchModels + ) + models = outcome.state.models + message = outcome.state.message + failed = outcome.state.failed + if let selected = outcome.selectedModel { + model = selected + } + } +} + +// MARK: - Connection validate + +struct MacProviderToolsRow: View { + @Environment(\.themePalette) private var palette + + let title: String + let validate: () async throws -> Void + let language: AppUILanguage + + @State private var isRunning = false + @State private var message: String? + @State private var failed = false + + var body: some View { + MacProviderSettingRow(title: title) { + HStack(alignment: .center, spacing: Spacing.sm) { + MacSettingsToolButton( + title: MacL10n.string("mac.settings.validate", language: language), + disabled: isRunning + ) { + Task { await runValidate() } + } + + if isRunning { + ProgressView() + .controlSize(.small) + } else if let message { + Text(message) + .font(MacSettingsType.hint) + .foregroundStyle(failed ? palette.danger : palette.accent) + .lineLimit(3) + .truncationMode(.tail) + } + } + } + } + + @MainActor + private func runValidate() async { + isRunning = true + failed = false + defer { isRunning = false } + + let outcome = await ProviderToolRunner.runValidate( + runningMessage: MacL10n.string("mac.settings.validating", language: language), + successMessage: MacL10n.string("mac.settings.validateSuccess", language: language), + validate: validate + ) + message = outcome.message + failed = outcome.failed + } +} + +// MARK: - Caption note row + +struct MacProviderNoteRow: View { + @Environment(\.themePalette) private var palette + + let text: String + + var body: some View { + Text(text) + .font(MacSettingsType.hint) + .foregroundStyle(palette.textTertiary) + .frame(maxWidth: .infinity, minHeight: MacMetrics.settingsRowMinHeight, alignment: .leading) + .padding(.horizontal, MacMetrics.settingsCardInset) + } +} diff --git a/OSGKeyboardMac/MacSettingsView.swift b/OSGKeyboardMac/MacSettingsView.swift index 808c66d..5c0609a 100644 --- a/OSGKeyboardMac/MacSettingsView.swift +++ b/OSGKeyboardMac/MacSettingsView.swift @@ -1,10 +1,8 @@ // MacSettingsView.swift // OSGKeyboard · Mac // -// Settings uses native grouped `Form` for correct control layout (Picker / -// Toggle / LabeledContent). Title and Form share the same plain -// `pageHorizontalInset` padding (Form scroll margins are zeroed first) so -// card chrome lines up with the page title. +// Settings uses native grouped `Form`. LLM / ASR provider blocks use a merged +// `MacSettingsProviderCard` with responsive rows and theme-aware controls. import SwiftUI #if os(macOS) @@ -20,8 +18,6 @@ struct MacSettingsView: View { @AppStorage(MacOnboardingState.storageKey) private var hasCompletedMacOnboarding = true @State private var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted - @State private var showProviderPicker = false - @State private var showAsrProviderPicker = false private var lang: AppUILanguage { viewModel.config.uiLanguage } private let recognitionLocales: [(id: String, key: String, fallback: String)] = [ @@ -41,33 +37,25 @@ struct MacSettingsView: View { subtitle: MacL10n.string("mac.page.settings.subtitle", language: lang) ) - Form { - generalSection - recognitionSection - polishProviderSection - if viewModel.config.engineMode == "cloud" { - asrProviderSection - .transition(.opacity) + ScrollView { + LazyVStack(alignment: .leading, spacing: Spacing.xl) { + generalSection + recognitionSection + if viewModel.config.engineMode == "cloud" { + asrProviderSection + .transition(.opacity) + } + if viewModel.config.engineMode == "local" { + MacLocalASRModelSettingsView(viewModel: viewModel) + .transition(.opacity) + } + polishProviderSection + inputSection + legalSection } - if viewModel.config.engineMode == "local" { - MacLocalASRModelSettingsView(viewModel: viewModel) - .transition(.opacity) - } - inputSection - legalSection + .padding(.horizontal, MacMetrics.pageHorizontalInset) + .padding(.bottom, Spacing.md) } - .formStyle(.grouped) - // Zero Form's own scroll margins, then inset via padding so the - // section cards line up with MacPageHeader (contentMargins alone - // does not match plain padding on macOS). - // - // grouped Form adds its own built-in section inset on top of our - // padding, so cards sat ~`groupedFormSectionInset` wider than the - // History page. Subtract that inset here so the card OUTER edge - // lands on `pageHorizontalInset` (40pt), matching History and the - // page title's left edge. - .contentMargins(.horizontal, 0, for: .scrollContent) - .padding(.horizontal, MacMetrics.pageHorizontalInset - MacMetrics.groupedFormSectionInset) .tint(palette.accent) .scrollContentBackground(.hidden) .background(palette.background) @@ -80,79 +68,83 @@ struct MacSettingsView: View { // MARK: - General private var generalSection: some View { - Section(MacL10n.string("mac.settings.general", language: lang)) { - Picker(MacL10n.string("mac.settings.appearance", language: lang), selection: $appearanceRaw) { - ForEach(MacAppearancePreference.allCases) { pref in - Text(MacL10n.string(pref.labelKey, language: lang)).tag(pref.rawValue) + MacSettingsSection(title: MacL10n.string("mac.settings.general", language: lang)) { + VStack(spacing: MacMetrics.settingsRowGap) { + MacProviderSettingRow(title: MacL10n.string("mac.settings.appearance", language: lang)) { + MacInlinePicker( + selection: $appearanceRaw, + options: MacAppearancePreference.allCases.map { + MacInlinePickerOption(value: $0.rawValue, label: MacL10n.string($0.labelKey, language: lang)) + }, + fillsWidth: true + ) } - } - Picker(MacL10n.string("mac.settings.interfaceLanguage", language: lang), selection: interfaceLanguageBinding) { - ForEach(AppUILanguage.allCases, id: \.self) { language in - Text(MacL10n.string(language.labelKey, language: lang)).tag(language.rawValue) + MacProviderSettingRow(title: MacL10n.string("mac.settings.interfaceLanguage", language: lang)) { + MacInlinePicker( + selection: interfaceLanguageBinding, + options: AppUILanguage.allCases.map { + MacInlinePickerOption(value: $0.rawValue, label: MacL10n.string($0.labelKey, language: lang)) + }, + fillsWidth: true + ) } - } - Picker(MacL10n.string("mac.settings.recognitionLanguage", language: lang), selection: recognitionLanguageBinding) { - ForEach(recognitionLocales, id: \.id) { locale in - Text(localeLabel(locale)).tag(locale.id) + MacProviderSettingRow(title: MacL10n.string("mac.settings.recognitionLanguage", language: lang)) { + MacInlinePicker( + selection: recognitionLanguageBinding, + options: recognitionLocales.map { + MacInlinePickerOption(value: $0.id, label: localeLabel($0)) + }, + fillsWidth: true + ) } - } - MacSettingsICloudSyncRow(defaults: viewModel.defaults, language: lang) - MacDictionaryICloudSyncRow(defaults: viewModel.defaults, language: lang) + MacSettingsICloudSyncRow(defaults: viewModel.defaults, language: lang) + } } } // MARK: - Polish LLM private var polishProviderSection: some View { - Section(MacL10n.string("mac.settings.polishProvider", language: lang)) { - providerPickerRow( - title: MacL10n.string("mac.settings.service", language: lang), - provider: currentPolishProvider, - isPresented: $showProviderPicker - ) { - providerPickerList( + MacSettingsSection(title: MacL10n.string("mac.settings.polishProvider", language: lang)) { + MacSettingsProviderCard { + MacProviderPickerRow( + title: MacL10n.string("mac.settings.service", language: lang), providers: viewModel.polishSelectableProviders, - selectedId: viewModel.config.providerId - ) { provider in - viewModel.selectProvider(provider) - showProviderPicker = false - } - } - - LabeledContent { - SecureField(text: $viewModel.config.apiKey, prompt: Text(verbatim: "sk-…")) { - Text(MacL10n.string("mac.settings.apiKey", language: lang)) - } - .labelsHidden() - .macFieldStyle() - .frame(maxWidth: MacMetrics.controlWidth) - } label: { - Text(MacL10n.string("mac.settings.apiKey", language: lang)) - } - - LabeledContent { - TextField(text: $viewModel.config.baseURL, prompt: Text(verbatim: "")) { - Text(MacL10n.string("mac.settings.baseURL", language: lang)) - } - .labelsHidden() - .macFieldStyle() - .frame(maxWidth: MacMetrics.controlWidth) - } label: { - Text(MacL10n.string("mac.settings.baseURL", language: lang)) - } - - LabeledContent { - TextField(text: $viewModel.config.model, prompt: Text(verbatim: "")) { - Text(MacL10n.string("mac.settings.model", language: lang)) - } - .labelsHidden() - .macFieldStyle() - .frame(maxWidth: MacMetrics.controlWidth) - } label: { - Text(MacL10n.string("mac.settings.model", language: lang)) + selection: polishProviderBinding + ) + MacCredentialField( + title: MacL10n.string("mac.settings.apiKey", language: lang), + placeholder: "sk-…", + text: $viewModel.config.apiKey, + isSecret: true + ) + MacCredentialField( + title: MacL10n.string("mac.settings.baseURL", language: lang), + placeholder: currentPolishProvider.defaultBaseURL, + text: $viewModel.config.baseURL + ) + MacProviderModelRow( + title: MacL10n.string("mac.settings.model", language: lang), + placeholder: currentPolishProvider.defaultModel, + model: $viewModel.config.model, + apiKey: viewModel.config.apiKey, + fetchModels: fetchMacLLMModels, + language: lang + ) + .id(viewModel.config.providerId) + MacProviderThinkingRow( + title: MacL10n.string("mac.settings.thinking", language: lang), + subtitle: MacL10n.string("mac.settings.thinkingSubtitle", language: lang), + isOn: $viewModel.config.llmThinkingEnabled + ) + MacProviderToolsRow( + title: MacL10n.string("mac.settings.connectionCheck", language: lang), + validate: validateMacLLM, + language: lang + ) } } } @@ -160,154 +152,148 @@ struct MacSettingsView: View { // MARK: - Cloud ASR private var asrProviderSection: some View { - Section(MacL10n.string("mac.settings.asrProvider", language: lang)) { - providerPickerRow( - title: MacL10n.string("mac.settings.asrService", language: lang), - provider: currentAsrProvider, - isPresented: $showAsrProviderPicker - ) { - providerPickerList( + MacSettingsSection(title: MacL10n.string("mac.settings.asrProvider", language: lang)) { + MacSettingsProviderCard { + MacProviderPickerRow( + title: MacL10n.string("mac.settings.asrService", language: lang), providers: viewModel.asrSelectableProviders, - selectedId: viewModel.config.asrProviderId - ) { provider in - viewModel.selectAsrProvider(provider) - showAsrProviderPicker = false + selection: asrProviderBinding + ) + if viewModel.config.asrProviderId == "volcengine" { + volcengineAsrRows + } else { + genericAsrRows } - } - - LabeledContent { - SecureField(text: $viewModel.config.asrApiKey, prompt: Text(verbatim: "sk-…")) { - Text(MacL10n.string("mac.settings.apiKey", language: lang)) - } - .labelsHidden() - .macFieldStyle() - .frame(maxWidth: MacMetrics.controlWidth) - } label: { - Text(MacL10n.string("mac.settings.asrApiKey", language: lang)) - } - - if CloudASRModelCatalog.strategy(for: viewModel.config.asrProviderId) == .prompt { - LabeledContent { - TextField(text: $viewModel.config.asrBaseURL, prompt: Text(verbatim: "")) { - Text(MacL10n.string("mac.settings.baseURL", language: lang)) - } - .labelsHidden() - .macFieldStyle() - .frame(maxWidth: MacMetrics.controlWidth) - } label: { - Text(MacL10n.string("mac.settings.baseURL", language: lang)) - } - } - - LabeledContent { - TextField(text: $viewModel.config.asrModel, prompt: Text(verbatim: "")) { - Text(MacL10n.string("mac.settings.asrModel", language: lang)) - } - .labelsHidden() - .macFieldStyle() - .frame(maxWidth: MacMetrics.controlWidth) - } label: { - Text(MacL10n.string("mac.settings.asrModel", language: lang)) + MacProviderToolsRow( + title: MacL10n.string("mac.settings.connectionCheck", language: lang), + validate: validateMacASR, + language: lang + ) } } } - private func providerPickerRow( - title: String, - provider: LLMProvider, - isPresented: Binding, - @ViewBuilder picker: @escaping () -> Content - ) -> some View { - LabeledContent(title) { - Button { - isPresented.wrappedValue = true - } label: { - HStack(spacing: 6) { - providerLogo(provider.id) - Text(provider.name) - .foregroundStyle(palette.textPrimary) - Image(systemName: "chevron.up.chevron.down") - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(palette.textTertiary) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .popover(isPresented: isPresented, arrowEdge: .bottom, content: picker) + @ViewBuilder + private var volcengineAsrRows: some View { + MacCredentialField( + title: MacL10n.string("mac.settings.volcengineAppId", language: lang), + placeholder: "APP ID", + text: Binding( + get: { macVolcengineFields.appID }, + set: { updateMacVolcengine(appID: $0) } + ), + isSecret: true + ) + MacCredentialField( + title: MacL10n.string("mac.settings.volcengineAccessToken", language: lang), + placeholder: "Access Token", + text: Binding( + get: { macVolcengineFields.accessToken }, + set: { updateMacVolcengine(accessToken: $0) } + ), + isSecret: true + ) + MacCredentialField( + title: MacL10n.string("mac.settings.volcengineResourceId", language: lang), + placeholder: CloudASRModelCatalog.defaultModel(for: "volcengine"), + text: Binding( + get: { macVolcengineFields.resourceID }, + set: { updateMacVolcengine(resourceID: $0) } + ), + defaultValue: CloudASRModelCatalog.defaultModel(for: "volcengine") + ) + MacProviderNoteRow(text: MacL10n.string("mac.settings.volcengineNote", language: lang)) + } + + @ViewBuilder + private var genericAsrRows: some View { + if CloudASRModelCatalog.showsASREndpointField(for: viewModel.config.asrProviderId) { + MacCredentialField( + title: MacL10n.string("mac.settings.baseURL", language: lang), + placeholder: currentAsrProvider.defaultBaseURL, + text: $viewModel.config.asrBaseURL + ) } + + MacCredentialField( + title: MacL10n.string("mac.settings.asrApiKey", language: lang), + placeholder: "sk-…", + text: $viewModel.config.asrApiKey, + isSecret: true + ) + MacProviderModelRow( + title: MacL10n.string("mac.settings.asrModel", language: lang), + placeholder: CloudASRModelCatalog.defaultModel(for: viewModel.config.asrProviderId), + model: $viewModel.config.asrModel, + apiKey: viewModel.config.asrApiKey, + fetchModels: fetchMacASRModels, + language: lang + ) + .id(viewModel.config.asrProviderId) } // MARK: - Recognition method private var recognitionSection: some View { - Section(MacL10n.string("mac.settings.recognition", language: lang)) { - methodRow( + MacSettingsSection(title: MacL10n.string("mac.settings.recognition", language: lang)) { + VStack(spacing: MacMetrics.settingsRowGap) { + methodRow( title: MacL10n.string("mac.settings.cloudEngine", language: lang), subtitle: MacL10n.string("mac.settings.cloudEngineDesc", language: lang), systemImage: "cloud", selected: viewModel.config.engineMode == "cloud" ) { withAnimation(Motion.soft) { viewModel.setEngineMode("cloud") } } - - methodRow( + methodRow( title: MacL10n.string("mac.settings.localEngine", language: lang), subtitle: MacL10n.string("mac.settings.localEngineDesc", language: lang), systemImage: "cpu", selected: viewModel.config.engineMode == "local" ) { withAnimation(Motion.soft) { viewModel.setEngineMode("local") } } + } } } // MARK: - Hotkey / paste private var inputSection: some View { - Section(MacL10n.string("mac.settings.input", language: lang)) { - Toggle(isOn: hotkeyBinding) { - rowLabel( - MacL10n.string("mac.settings.hotkey", language: lang), - subtitle: MacL10n.string("mac.settings.hotkeyDesc", language: lang) - ) - } - - Picker(selection: hotkeyTriggerBinding) { - ForEach(MacHotkeyTrigger.allCases) { trigger in - Text(MacL10n.string(trigger.labelKey, language: lang)) - .tag(trigger.rawValue) - } - } label: { - rowLabel( - MacL10n.string("mac.settings.hotkeyTrigger", language: lang), - subtitle: MacL10n.string("mac.settings.hotkeyTriggerDesc", language: lang) - ) - } - .disabled(!viewModel.hotkeyEnabled) - - Toggle(isOn: autoPasteBinding) { - rowLabel( - MacL10n.string("mac.settings.autoPaste", language: lang), - subtitle: MacL10n.string("mac.settings.autoPasteDesc", language: lang) - ) - } - - LabeledContent { - HStack(spacing: Spacing.sm) { - Label( - accessibilityTrusted ? accessibilityStatusGranted : accessibilityStatusNeeded, - systemImage: accessibilityTrusted ? "checkmark.circle.fill" : "exclamationmark.circle" + MacSettingsSection(title: MacL10n.string("mac.settings.input", language: lang)) { + VStack(spacing: MacMetrics.settingsRowGap) { + MacProviderSettingRow(title: MacL10n.string("mac.settings.hotkeyTrigger", language: lang)) { + MacInlinePicker( + selection: hotkeyTriggerBinding, + options: MacHotkeyTrigger.allCases.map { + MacInlinePickerOption(value: $0.rawValue, label: MacL10n.string($0.labelKey, language: lang)) + }, + fillsWidth: true ) - .font(TypeStyle.caption) - .foregroundStyle(accessibilityTrusted ? palette.accent : palette.warning) - .contentTransition(.opacity) - .animation(Motion.quick, value: accessibilityTrusted) + } + MacProviderSettingRow( + title: MacL10n.string("mac.settings.autoPaste", language: lang), + verticalAlignment: .center + ) { + Toggle("", isOn: autoPasteBinding) + .labelsHidden() + .toggleStyle(MacToggleStyle()) + } + MacProviderSettingRow( + title: MacL10n.string("mac.settings.accessibility", language: lang), + verticalAlignment: .center + ) { + HStack(spacing: Spacing.sm) { + MacSettingsToolButton(title: MacL10n.string("mac.settings.openAccessibility", language: lang)) { + openAccessibilitySettings() + } - Button(MacL10n.string("mac.settings.openAccessibility", language: lang)) { - openAccessibilitySettings() + Label( + accessibilityTrusted ? accessibilityStatusGranted : accessibilityStatusNeeded, + systemImage: accessibilityTrusted ? "checkmark.circle.fill" : "exclamationmark.circle" + ) + .font(TypeStyle.caption) + .foregroundStyle(accessibilityTrusted ? palette.accent : palette.warning) + .contentTransition(.opacity) + .animation(Motion.quick, value: accessibilityTrusted) } } - } label: { - rowLabel( - MacL10n.string("mac.settings.accessibility", language: lang), - subtitle: MacL10n.string("mac.settings.accessibilityDesc", language: lang) - ) } } } @@ -315,37 +301,32 @@ struct MacSettingsView: View { // MARK: - Legal private var legalSection: some View { - Section(MacL10n.string("mac.settings.about", language: lang)) { - NavigationLink { - MacPrivacyPolicyView(uiLanguage: lang) - } label: { - Text(MacL10n.string("mac.settings.privacyPolicy", language: lang)) - } - - NavigationLink { - MacOpenSourceLicensesView(uiLanguage: lang) - } label: { - Text(MacL10n.string("mac.settings.thirdPartyLicenses", language: lang)) - } - - Button(MacL10n.string("mac.settings.restartOnboarding", language: lang)) { - hasCompletedMacOnboarding = false + MacSettingsSection(title: MacL10n.string("mac.settings.about", language: lang)) { + VStack(spacing: MacMetrics.settingsRowGap) { + NavigationLink { + MacPrivacyPolicyView(uiLanguage: lang) + } label: { + MacFormLinkRow(title: MacL10n.string("mac.settings.privacyPolicy", language: lang)) + } + .buttonStyle(.plain) + NavigationLink { + MacOpenSourceLicensesView(uiLanguage: lang) + } label: { + MacFormLinkRow(title: MacL10n.string("mac.settings.thirdPartyLicenses", language: lang)) + } + .buttonStyle(.plain) + Button(MacL10n.string("mac.settings.restartOnboarding", language: lang)) { + hasCompletedMacOnboarding = false + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity, minHeight: MacMetrics.settingsRowMinHeight, alignment: .leading) + .padding(.horizontal, MacMetrics.settingsCardInset) } } } // MARK: - Row helpers - private func rowLabel(_ title: String, subtitle: String) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(title) - Text(subtitle) - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } - } - private func methodRow( title: String, subtitle: String, @@ -372,6 +353,8 @@ struct MacSettingsView: View { .foregroundStyle(selected ? palette.accent : palette.textTertiary) .contentTransition(.symbolEffect(.replace)) } + .frame(minHeight: MacMetrics.settingsRowMinHeight) + .padding(.horizontal, MacMetrics.settingsCardInset) .animation(Motion.quick, value: selected) .contentShape(Rectangle()) } @@ -390,60 +373,59 @@ struct MacSettingsView: View { ?? LLMProvider.presets[0] } - private func providerPickerList( - providers: [LLMProvider], - selectedId: String, - onSelect: @escaping (LLMProvider) -> Void - ) -> some View { - VStack(spacing: 0) { - ForEach(providers) { provider in - Button { - onSelect(provider) - } label: { - HStack(spacing: Spacing.sm) { - providerLogo(provider.id) - Text(provider.name) - .foregroundStyle(palette.textPrimary) - Spacer(minLength: Spacing.md) - if provider.id == selectedId { - Image(systemName: "checkmark") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(palette.accent) - } - } - .padding(.horizontal, Spacing.md) - .frame(height: 34) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - } - .padding(.vertical, Spacing.xs) - .frame(width: 260) + private var macVolcengineFields: VolcengineASRFields { + VolcengineASRFields.parse( + apiKey: viewModel.config.asrApiKey, + resourceFallback: viewModel.config.asrModel.isEmpty + ? CloudASRModelCatalog.defaultModel(for: "volcengine") + : viewModel.config.asrModel + ) } - /// Brand mark tinted to the current label colour (black on light / white - /// on dark). Template rendering + an explicit frame make the vector assets - /// resolve at a text-matched size inside the pop-up menu — without a size - /// hint they collapse to zero and disappear. - @ViewBuilder - private func providerLogo(_ providerId: String) -> some View { - if let asset = ProviderLogo.assetName(for: providerId) { - Image(asset) - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - .foregroundStyle(palette.textPrimary) + private func updateMacVolcengine(appID: String? = nil, accessToken: String? = nil, resourceID: String? = nil) { + var fields = macVolcengineFields + if let appID { fields.appID = appID } + if let accessToken { fields.accessToken = accessToken } + if let resourceID { + fields.resourceID = resourceID + viewModel.config.asrModel = resourceID } + viewModel.config.asrApiKey = fields.encodedAPIKey } - @ViewBuilder - private func providerLabel(_ provider: LLMProvider) -> some View { - Label { - Text(provider.name) - } icon: { - providerLogo(provider.id) - } + private func validateMacLLM() async throws { + let client = LLMClientFactory.make( + providerId: viewModel.config.providerId, + baseURL: viewModel.config.baseURL, + apiKey: viewModel.config.apiKey, + model: viewModel.config.model, + thinkingEnabled: viewModel.config.llmThinkingEnabled + ) + _ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.") + } + + private func fetchMacLLMModels() async throws -> [String] { + try await ProviderModelService.listLLMModels( + providerId: viewModel.config.providerId, + baseURL: viewModel.config.baseURL, + apiKey: viewModel.config.apiKey, + currentModel: viewModel.config.model + ) + } + + private func validateMacASR() async throws { + let persisted = AppGroupStore(defaults: viewModel.defaults) + let store = LiveConfigurationStore(config: viewModel.config, fallback: persisted) + try await CloudASRConnectionCheck.validate(store: store) + } + + private func fetchMacASRModels() async throws -> [String] { + try await ProviderModelService.listASRModels( + providerId: viewModel.config.asrProviderId, + baseURL: viewModel.config.asrBaseURL, + apiKey: viewModel.config.asrApiKey, + currentModel: viewModel.config.asrModel + ) } // MARK: - Bindings @@ -455,17 +437,28 @@ struct MacSettingsView: View { ) } - private var providerBinding: Binding { + private var polishProviderBinding: Binding { Binding( get: { viewModel.config.providerId }, set: { newId in - if let provider = viewModel.selectableProviders.first(where: { $0.id == newId }) { + if let provider = viewModel.polishSelectableProviders.first(where: { $0.id == newId }) { viewModel.selectProvider(provider) } } ) } + private var asrProviderBinding: Binding { + Binding( + get: { viewModel.config.asrProviderId }, + set: { newId in + if let provider = viewModel.asrSelectableProviders.first(where: { $0.id == newId }) { + viewModel.selectAsrProvider(provider) + } + } + ) + } + private var recognitionLanguageBinding: Binding { Binding( get: { @@ -478,13 +471,6 @@ struct MacSettingsView: View { ) } - private var hotkeyBinding: Binding { - Binding( - get: { viewModel.hotkeyEnabled }, - set: { viewModel.setHotkeyEnabled($0) } - ) - } - private var hotkeyTriggerBinding: Binding { Binding( get: { viewModel.hotkeyTrigger.rawValue }, diff --git a/OSGKeyboardMac/MacSherpaONNXRunner.swift b/OSGKeyboardMac/MacSherpaONNXRunner.swift index c49744c..5602691 100644 --- a/OSGKeyboardMac/MacSherpaONNXRunner.swift +++ b/OSGKeyboardMac/MacSherpaONNXRunner.swift @@ -231,3 +231,23 @@ enum MacSherpaONNXRunner { } } } + +enum MacQwen3LanguageHint { + /// Map persisted BCP-47 locale ids to Qwen3 prompt language names. + /// Returns `nil` for auto-detect. + static func from(locale: Locale) -> String? { + let raw = locale.identifier.lowercased() + if raw.isEmpty || raw == "auto" { return nil } + if raw.hasPrefix("zh") { return "Chinese" } + if raw.hasPrefix("en") { return "English" } + if raw.hasPrefix("ja") { return "Japanese" } + if raw.hasPrefix("ko") { return "Korean" } + if raw.hasPrefix("fr") { return "French" } + if raw.hasPrefix("de") { return "German" } + if raw.hasPrefix("es") { return "Spanish" } + if raw.hasPrefix("pt") { return "Portuguese" } + if raw.hasPrefix("ru") { return "Russian" } + if raw.hasPrefix("ar") { return "Arabic" } + return nil + } +} diff --git a/OSGKeyboardShared/Core/Configuration/ConfigurationStore.swift b/OSGKeyboardShared/Core/Configuration/ConfigurationStore.swift index 45359c2..1992b35 100644 --- a/OSGKeyboardShared/Core/Configuration/ConfigurationStore.swift +++ b/OSGKeyboardShared/Core/Configuration/ConfigurationStore.swift @@ -27,6 +27,7 @@ public protocol ConfigurationStore: Sendable { var engineMode: String { get } var polishIntensity: PolishIntensity { get } + var llmThinkingEnabled: Bool { get } var personalDictionary: PersonalDictionary { get } /// Foreground-app context for polish prompts (keyboard extension publishes this). diff --git a/OSGKeyboardShared/Core/Configuration/LiveConfigurationStore.swift b/OSGKeyboardShared/Core/Configuration/LiveConfigurationStore.swift new file mode 100644 index 0000000..864a8cf --- /dev/null +++ b/OSGKeyboardShared/Core/Configuration/LiveConfigurationStore.swift @@ -0,0 +1,114 @@ +// LiveConfigurationStore.swift +// OSGKeyboard · Shared +// +// In-memory settings snapshot for connection checks and model probes. +// Reads the values the user is editing — not a delayed Keychain re-fetch. + +import Foundation + +public struct LiveConfigurationSnapshot { + public let providerId: String + public let baseURL: String + public let apiKey: String + public let model: String + public let asrProviderId: String + public let asrBaseURL: String + public let asrApiKey: String + public let asrModel: String + public let engineMode: String + public let polishIntensity: PolishIntensity + public let llmThinkingEnabled: Bool + public let personalDictionary: PersonalDictionary + public let detectedAppContext: (context: AppContext, observedAt: Date)? + public let cloudASRPersistence: UserDefaults + + public init( + providerId: String, + baseURL: String, + apiKey: String, + model: String, + asrProviderId: String, + asrBaseURL: String, + asrApiKey: String, + asrModel: String, + engineMode: String, + polishIntensity: PolishIntensity, + llmThinkingEnabled: Bool, + personalDictionary: PersonalDictionary, + detectedAppContext: (context: AppContext, observedAt: Date)?, + cloudASRPersistence: UserDefaults + ) { + self.providerId = providerId + self.baseURL = baseURL + self.apiKey = apiKey + self.model = model + self.asrProviderId = asrProviderId + self.asrBaseURL = asrBaseURL + self.asrApiKey = asrApiKey + self.asrModel = asrModel + self.engineMode = engineMode + self.polishIntensity = polishIntensity + self.llmThinkingEnabled = llmThinkingEnabled + self.personalDictionary = personalDictionary + self.detectedAppContext = detectedAppContext + self.cloudASRPersistence = cloudASRPersistence + } + + /// Build from live `ProviderConfig` plus persisted App Group extras. + public init(config: ProviderConfig, fallback: AppGroupStore) { + self.init( + providerId: config.providerId, + baseURL: config.baseURL, + apiKey: config.apiKey, + model: config.model, + asrProviderId: config.asrProviderId, + asrBaseURL: config.asrBaseURL, + asrApiKey: config.asrApiKey, + asrModel: config.asrModel, + engineMode: config.engineMode, + polishIntensity: config.polishIntensity, + llmThinkingEnabled: config.llmThinkingEnabled, + personalDictionary: fallback.personalDictionary, + detectedAppContext: fallback.detectedAppContext, + cloudASRPersistence: fallback.defaults + ) + } +} + +/// Ephemeral `ConfigurationStore` backed by a user-edited snapshot. +public struct LiveConfigurationStore: ConfigurationStore, @unchecked Sendable { + private let snapshot: LiveConfigurationSnapshot + + public init(snapshot: LiveConfigurationSnapshot) { + self.snapshot = snapshot + } + + public init(config: ProviderConfig, fallback: AppGroupStore) { + self.init(snapshot: LiveConfigurationSnapshot(config: config, fallback: fallback)) + } + + public var providerId: String { snapshot.providerId } + public var baseURL: String { snapshot.baseURL } + public var apiKey: String { snapshot.apiKey } + public var model: String { snapshot.model } + public var asrProviderId: String { snapshot.asrProviderId } + public var asrBaseURL: String { snapshot.asrBaseURL } + public var asrApiKey: String { snapshot.asrApiKey } + public var asrModel: String { snapshot.asrModel } + public var engineMode: String { snapshot.engineMode } + public var polishIntensity: PolishIntensity { snapshot.polishIntensity } + public var llmThinkingEnabled: Bool { snapshot.llmThinkingEnabled } + public var personalDictionary: PersonalDictionary { snapshot.personalDictionary } + public var detectedAppContext: (context: AppContext, observedAt: Date)? { snapshot.detectedAppContext } + public var cloudASRPersistence: UserDefaults { snapshot.cloudASRPersistence } + + public func makeClient() -> LLMClient { + LLMClientFactory.make( + providerId: providerId, + baseURL: baseURL, + apiKey: apiKey, + model: model, + thinkingEnabled: llmThinkingEnabled + ) + } +} diff --git a/OSGKeyboardShared/DesignSystem/SevenDayUsageChart.swift b/OSGKeyboardShared/DesignSystem/SevenDayUsageChart.swift new file mode 100644 index 0000000..6b48845 --- /dev/null +++ b/OSGKeyboardShared/DesignSystem/SevenDayUsageChart.swift @@ -0,0 +1,133 @@ +// SevenDayUsageChart.swift +// OSGKeyboard · Shared +// +// 7-day dictation bar chart. Platform shells wrap this in their own page +// layout; the chart itself only needs points + UI language. + +import Charts +import SwiftUI + +public struct SevenDayUsageChart: View { + @Environment(\.themePalette) private var palette + + public let points: [UsageStatisticsStore.DailyUsagePoint] + public let language: AppUILanguage + /// Bar area height. Phone stacked layout uses a shorter value. + public var chartMinHeight: CGFloat + /// When true, wrap content in `UsageSurfaceCard` (default). Pass false if + /// the caller already provides a surface. + public var embedsInCard: Bool + /// When true (iPad split), the chart fills all available height. When false + /// (phone stacked home), the bar area is a fixed `chartMinHeight` so the card + /// stays compact and does not steal space from surrounding content. + public var expands: Bool + + public init( + points: [UsageStatisticsStore.DailyUsagePoint], + language: AppUILanguage, + chartMinHeight: CGFloat = 96, + embedsInCard: Bool = true, + expands: Bool = true + ) { + self.points = points + self.language = language + self.chartMinHeight = chartMinHeight + self.embedsInCard = embedsInCard + self.expands = expands + } + + private var total: Int { + points.reduce(0) { $0 + $1.value } + } + + private var maxValue: Int { + max(points.map(\.value).max() ?? 0, 1) + } + + private var chartLocale: Locale { + Locale(identifier: language.resolvedLanguageCode()) + } + + public var body: some View { + Group { + if embedsInCard { + UsageSurfaceCard(padding: Spacing.md) { + chartContent + } + } else { + chartContent + } + } + } + + private var chartContent: some View { + VStack(alignment: .leading, spacing: Spacing.sm) { + header + chart + .frame(maxWidth: .infinity, maxHeight: expands ? .infinity : nil) + } + .frame( + maxWidth: .infinity, + maxHeight: expands ? .infinity : nil, + alignment: .topLeading + ) + } + + // MARK: - Header + + private var header: some View { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text(SharedL10n.string("stat.weekChart.title", language: language).uppercased()) + .font(TypeStyle.caption2) + .tracking(0.6) + .foregroundStyle(palette.textTertiary) + Text(SharedL10n.string("stat.weekChart.caption", language: language)) + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + } + Spacer(minLength: Spacing.sm) + Text(UsageStatisticsStore.formatCount(total, language: language)) + .font(TypeStyle.title2) + .foregroundStyle(palette.accent) + .lineLimit(1) + .minimumScaleFactor(0.7) + .contentTransition(.numericText()) + .animation(Motion.soft, value: total) + } + } + + // MARK: - Bars + + @ViewBuilder + private var chart: some View { + if total == 0 { + Text(SharedL10n.string("stat.weekChart.empty", language: language)) + .font(TypeStyle.caption) + .foregroundStyle(palette.textTertiary) + .frame(maxWidth: .infinity, maxHeight: expands ? .infinity : nil, alignment: .center) + .multilineTextAlignment(.center) + .frame(height: expands ? nil : chartMinHeight) + .frame(minHeight: expands ? chartMinHeight : nil) + } else { + Chart(points) { point in + BarMark( + x: .value("day", point.date, unit: .day), + y: .value("chars", point.value) + ) + .cornerRadius(4) + .foregroundStyle(palette.accent.gradient) + } + .chartYScale(domain: 0...max(1, Int(ceil(Double(maxValue) * 1.15)))) + .chartYAxis(.hidden) + .chartXAxis { + AxisMarks(values: points.map(\.date)) { _ in + AxisValueLabel(format: .dateTime.weekday(.narrow)) + } + } + .environment(\.locale, chartLocale) + .frame(height: expands ? nil : chartMinHeight) + .frame(minHeight: expands ? chartMinHeight : nil) + } + } +} diff --git a/OSGKeyboardShared/DesignSystem/Theme.swift b/OSGKeyboardShared/DesignSystem/Theme.swift index 5ef1d57..3dcd704 100644 --- a/OSGKeyboardShared/DesignSystem/Theme.swift +++ b/OSGKeyboardShared/DesignSystem/Theme.swift @@ -162,10 +162,13 @@ public enum Radius { // MARK: - Typography public enum SettingsListMetrics { - /// Single-line list rows (provider, footer link, picker). - public static let singleLineMinHeight: CGFloat = 52 - /// Two-line rows (engine option, labeled API field). - public static let doubleLineMinHeight: CGFloat = 72 + /// Floor for settings list rows (slightly above HIG 44pt). + /// Row height grows with content; this only enforces a touch-target minimum. + public static let singleLineMinHeight: CGFloat = 48 + /// Horizontal inset inside a settings list row. + public static let rowHorizontalPadding: CGFloat = Spacing.md + /// Vertical inset inside a settings list row (`Spacing.sm`). + public static let rowVerticalPadding: CGFloat = 12 /// Space between a section label and its card. public static let sectionLabelSpacing: CGFloat = Spacing.sm } @@ -263,6 +266,19 @@ private struct SecondaryButtonModifier: ViewModifier { } public extension View { + /// Standard settings list row insets: horizontal + vertical padding and a + /// touch-target floor. Height grows with content — do not hand-roll + /// per-section padding/`minHeight` for ordinary settings rows. + func settingsListRow( + minHeight: CGFloat = SettingsListMetrics.singleLineMinHeight, + alignment: Alignment = .center + ) -> some View { + self + .padding(.horizontal, SettingsListMetrics.rowHorizontalPadding) + .padding(.vertical, SettingsListMetrics.rowVerticalPadding) + .frame(minHeight: minHeight, alignment: alignment) + } + /// Standard card surface used in the main app. func cardSurface(padding: CGFloat = Spacing.md) -> some View { modifier(CardSurfaceModifier(padding: padding)) diff --git a/OSGKeyboardShared/DesignSystem/UsageStatCard.swift b/OSGKeyboardShared/DesignSystem/UsageStatCard.swift new file mode 100644 index 0000000..3b8ae74 --- /dev/null +++ b/OSGKeyboardShared/DesignSystem/UsageStatCard.swift @@ -0,0 +1,108 @@ +// UsageStatCard.swift +// OSGKeyboard · Shared +// +// Single cumulative metric tile. Compact = title / value / caption stack; +// prominent = horizontal hero bar for the primary word-count metric. + +import SwiftUI + +public struct UsageStatCard: View { + @Environment(\.themePalette) private var palette + + public let title: String + public let value: String + public let caption: String + public var systemImage: String? + public var accent: Bool + /// Hero metric: wide horizontal layout for the primary word count. + public var prominent: Bool + + public init( + title: String, + value: String, + caption: String, + systemImage: String? = nil, + accent: Bool = false, + prominent: Bool = false + ) { + self.title = title + self.value = value + self.caption = caption + self.systemImage = systemImage + self.accent = accent + self.prominent = prominent + } + + public var body: some View { + UsageSurfaceCard(padding: Spacing.md) { + if prominent { + prominentBody + } else { + compactBody + } + } + } + + private var compactBody: some View { + VStack(alignment: .leading, spacing: Spacing.xs) { + HStack { + Text(title.uppercased()) + .font(TypeStyle.caption2) + .tracking(0.6) + .foregroundStyle(palette.textTertiary) + Spacer() + if let systemImage { + Image(systemName: systemImage) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(accent ? palette.accent : palette.textTertiary) + .symbolRenderingMode(.hierarchical) + } + } + Text(value) + .font(TypeStyle.title2) + .foregroundStyle(accent ? palette.accent : palette.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.7) + .contentTransition(.numericText()) + .animation(Motion.soft, value: value) + Text(caption) + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + /// Wide "hero bar": icon badge + title/caption left, big number right. + private var prominentBody: some View { + HStack(spacing: Spacing.md) { + if let systemImage { + ZStack { + Circle() + .fill(palette.accentMuted) + .frame(width: 44, height: 44) + Image(systemName: systemImage) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(palette.accent) + .symbolRenderingMode(.hierarchical) + } + } + VStack(alignment: .leading, spacing: 2) { + Text(title.uppercased()) + .font(TypeStyle.caption2) + .tracking(0.6) + .foregroundStyle(palette.textTertiary) + Text(caption) + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + } + Spacer(minLength: Spacing.md) + Text(value) + .font(.system(size: 34, weight: .bold)) + .foregroundStyle(accent ? palette.accent : palette.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.6) + .contentTransition(.numericText()) + .animation(Motion.soft, value: value) + } + } +} diff --git a/OSGKeyboardShared/DesignSystem/UsageStatsCluster.swift b/OSGKeyboardShared/DesignSystem/UsageStatsCluster.swift new file mode 100644 index 0000000..e327202 --- /dev/null +++ b/OSGKeyboardShared/DesignSystem/UsageStatsCluster.swift @@ -0,0 +1,196 @@ +// UsageStatsCluster.swift +// OSGKeyboard · Shared +// +// Cross-platform home / dashboard stats: 7-day chart + cumulative metrics. +// Callers observe their store and pass plain values — Shared stays unbound +// from platform singletons. + +import SwiftUI + +public struct UsageStatsCluster: View { + @Environment(\.themePalette) private var palette + + public enum Layout: Sendable, Equatable { + /// Chart left, 2×2 `UsageStatCard` grid right (Mac / iPad). + case split + /// Chart above a compact single-card 2×2 grid (iPhone). + case stacked + } + + /// 手机端 2×2 统计网格的紧凑固定高度(沿用旧版 HomeStatsCard 数值)。 + static let compactGridHeight: CGFloat = 166 + + public let layout: Layout + public let language: AppUILanguage + public let points: [UsageStatisticsStore.DailyUsagePoint] + public let dictationCharacterCount: Int + public let dictationDurationSeconds: TimeInterval + public let translationCharacterCount: Int + public let dictionaryTermCount: Int + /// 小屏(如 iPhone SE)收紧 stacked 图表高度,把空间让给下方的输入框。 + public let compact: Bool + + public init( + layout: Layout, + language: AppUILanguage, + points: [UsageStatisticsStore.DailyUsagePoint], + dictationCharacterCount: Int, + dictationDurationSeconds: TimeInterval, + translationCharacterCount: Int, + dictionaryTermCount: Int, + compact: Bool = false + ) { + self.layout = layout + self.language = language + self.points = points + self.dictationCharacterCount = dictationCharacterCount + self.dictationDurationSeconds = dictationDurationSeconds + self.translationCharacterCount = translationCharacterCount + self.dictionaryTermCount = dictionaryTermCount + self.compact = compact + } + + public var body: some View { + switch layout { + case .split: + splitBody + case .stacked: + stackedBody + } + } + + // MARK: - Split (Mac / iPad) + + private var splitBody: some View { + HStack(alignment: .top, spacing: Spacing.md) { + SevenDayUsageChart(points: points, language: language) + .frame(maxWidth: .infinity, maxHeight: .infinity) + splitStatGrid + .frame(maxWidth: .infinity) + } + .fixedSize(horizontal: false, vertical: true) + } + + private var splitStatGrid: some View { + VStack(spacing: Spacing.md) { + HStack(spacing: Spacing.md) { + UsageStatCard( + title: SharedL10n.string("stat.words", language: language), + value: UsageStatisticsStore.formatCount(dictationCharacterCount, language: language), + caption: SharedL10n.string("stat.transcribed", language: language), + systemImage: "text.alignleft", + accent: true + ) + UsageStatCard( + title: SharedL10n.string("stat.dictationTime", language: language), + value: UsageStatisticsStore.formatDuration(dictationDurationSeconds, language: language), + caption: SharedL10n.string("stat.cumulativeDuration", language: language), + systemImage: "waveform" + ) + } + HStack(spacing: Spacing.md) { + UsageStatCard( + title: SharedL10n.string("stat.translation", language: language), + value: UsageStatisticsStore.formatCount(translationCharacterCount, language: language), + caption: SharedL10n.string("stat.cumulativeTranslation", language: language), + systemImage: "character.bubble" + ) + UsageStatCard( + title: SharedL10n.string("stat.dictionary", language: language), + value: UsageStatisticsStore.formatCount(dictionaryTermCount, language: language), + caption: SharedL10n.string("stat.customTerms", language: language), + systemImage: "character.book.closed" + ) + } + } + } + + // MARK: - Stacked (iPhone) + + private var stackedBody: some View { + VStack(spacing: compact ? Spacing.sm : Spacing.md) { + SevenDayUsageChart( + points: points, + language: language, + chartMinHeight: compact ? 72 : 96, + expands: false + ) + compactStatGrid + } + } + + /// Phone-friendly 2×2: value + label only, single card with hairline dividers. + private var compactStatGrid: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + compactCell( + systemImage: "waveform", + value: UsageStatisticsStore.formatDuration(dictationDurationSeconds, language: language), + label: SharedL10n.string("stat.dictationTime", language: language) + ) + compactDivider + compactCell( + systemImage: "text.alignleft", + value: UsageStatisticsStore.formatCount(dictationCharacterCount, language: language), + label: SharedL10n.string("stat.words", language: language) + ) + } + Rectangle() + .fill(palette.divider) + .frame(height: 0.5) + HStack(spacing: 0) { + compactCell( + systemImage: "character.bubble", + value: UsageStatisticsStore.formatCount(translationCharacterCount, language: language), + label: SharedL10n.string("stat.translation", language: language) + ) + compactDivider + compactCell( + systemImage: "character.book.closed", + value: UsageStatisticsStore.formatCount(dictionaryTermCount, language: language), + label: SharedL10n.string("stat.dictionary", language: language) + ) + } + } + // 锁定紧凑固定高度(对齐旧版 HomeStatsCard 的 166pt),避免格子按内容撑高。 + .frame(height: UsageStatsCluster.compactGridHeight) + .background(palette.surface) + .clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) + } + + private var compactDivider: some View { + Rectangle() + .fill(palette.divider) + .frame(width: 0.5) + } + + private func compactCell(systemImage: String, value: String, label: String) -> some View { + HStack(alignment: .top, spacing: Spacing.xs) { + VStack(alignment: .leading, spacing: Spacing.xs) { + Text(value) + .font(.system(size: 24, weight: .semibold, design: .rounded)) + .foregroundStyle(palette.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.75) + .contentTransition(.numericText()) + .animation(Motion.soft, value: value) + Text(label) + .font(TypeStyle.caption2) + .foregroundStyle(palette.textSecondary) + .lineLimit(1) + .minimumScaleFactor(0.85) + } + Spacer(minLength: Spacing.xs) + Image(systemName: systemImage) + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(palette.accent) + .padding(.top, 2) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(Spacing.md) + } +} diff --git a/OSGKeyboardShared/DesignSystem/UsageSurfaceCard.swift b/OSGKeyboardShared/DesignSystem/UsageSurfaceCard.swift new file mode 100644 index 0000000..04fc087 --- /dev/null +++ b/OSGKeyboardShared/DesignSystem/UsageSurfaceCard.swift @@ -0,0 +1,36 @@ +// UsageSurfaceCard.swift +// OSGKeyboard · Shared +// +// Flat semantic surface used by home / dashboard stats on every platform. +// Deliberately shadowless — hierarchy comes from fill + hairline border. + +import SwiftUI + +public struct UsageSurfaceCard: View { + @Environment(\.themePalette) private var palette + + public var padding: CGFloat + public var cornerRadius: CGFloat + @ViewBuilder public var content: () -> Content + + public init( + padding: CGFloat = Spacing.md, + cornerRadius: CGFloat = Radius.medium, + @ViewBuilder content: @escaping () -> Content + ) { + self.padding = padding + self.cornerRadius = cornerRadius + self.content = content + } + + public var body: some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + + content() + .padding(padding) + .background(palette.surface, in: shape) + .overlay( + shape.stroke(palette.divider, lineWidth: 0.5) + ) + } +} diff --git a/OSGKeyboardShared/Models/AppGroupConfiguration.swift b/OSGKeyboardShared/Models/AppGroupConfiguration.swift index 126acf4..9887f74 100644 --- a/OSGKeyboardShared/Models/AppGroupConfiguration.swift +++ b/OSGKeyboardShared/Models/AppGroupConfiguration.swift @@ -8,6 +8,11 @@ import Foundation public struct AppGroupConfiguration: Sendable, Equatable { + /// Default polish LLM for fresh installs (local + cloud pickers). + public static let defaultPolishProviderId = "deepseek" + /// Default cloud ASR provider for fresh installs (independent from polish). + public static let defaultCloudASRProviderId = "volcengine" + // MARK: - Keys public enum Keys { @@ -31,6 +36,7 @@ public struct AppGroupConfiguration: Sendable, Equatable { public static let handednessPreference = "config.handednessPreference" public static let cursorDragNavigationEnabled = "config.cursorDragNavigationEnabled" public static let polishIntensity = "config.polishIntensity" + public static let llmThinkingEnabled = "config.llmThinkingEnabled" public static let detectedAppContext = "config.detectedAppContext" public static let detectedAppContextAt = "config.detectedAppContextAt" public static let personalDictionary = "config.personalDictionary.v1" @@ -70,6 +76,8 @@ public struct AppGroupConfiguration: Sendable, Equatable { public var handednessPreference: HandednessPreference public var cursorDragNavigationEnabled: Bool public var polishIntensity: PolishIntensity + /// Enables provider-specific reasoning / thinking controls for polish LLM requests. + public var llmThinkingEnabled: Bool public var personalDictionary: PersonalDictionary /// Opt-in iCloud KVS sync for the personal dictionary (main app only). public var personalDictionaryICloudSyncEnabled: Bool @@ -150,7 +158,9 @@ public struct AppGroupConfiguration: Sendable, Equatable { OpenAICompatibleClient( baseURL: baseURL, apiKey: apiKey, - model: model + model: model, + providerId: providerId, + thinkingEnabled: llmThinkingEnabled ) } @@ -193,8 +203,9 @@ public struct AppGroupConfiguration: Sendable, Equatable { /// Loads configuration from a known-available UserDefaults suite. public static func load(fromAvailable defaults: UserDefaults) -> AppGroupConfiguration { + let storedProviderId = defaults.string(forKey: Keys.providerId) var config = AppGroupConfiguration( - providerId: defaults.string(forKey: Keys.providerId) ?? "openai", + providerId: storedProviderId ?? defaultPolishProviderId, baseURL: "", model: "", asrProviderId: defaults.string(forKey: Keys.asrProviderId) ?? "", @@ -228,6 +239,7 @@ public struct AppGroupConfiguration: Sendable, Equatable { return defaults.bool(forKey: Keys.cursorDragNavigationEnabled) }(), polishIntensity: resolvePolishIntensity(from: defaults), + llmThinkingEnabled: defaults.bool(forKey: Keys.llmThinkingEnabled), personalDictionary: decodePersonalDictionary(from: defaults), personalDictionaryICloudSyncEnabled: { if defaults.object(forKey: Keys.personalDictionaryICloudSyncEnabled) == nil { @@ -267,7 +279,8 @@ public struct AppGroupConfiguration: Sendable, Equatable { } if config.asrProviderId.isEmpty { - config.asrProviderId = config.providerId + // Pre-split installs only stored `providerId`; copy it so ASR keeps working. + config.asrProviderId = storedProviderId ?? defaultCloudASRProviderId defaults.set(config.asrProviderId, forKey: Keys.asrProviderId) } let asrPreset = LLMProvider.provider(id: config.asrProviderId) @@ -279,6 +292,17 @@ public struct AppGroupConfiguration: Sendable, Equatable { ?? CloudASRModelCatalog.defaultModel(for: config.asrProviderId) } + // Legacy qwen cloud ASR → bailian realtime (HTTP Flash path removed). + if config.asrProviderId == "qwen" { + let bailian = LLMProvider.provider(id: "bailian") + config.asrProviderId = "bailian" + config.asrBaseURL = bailian.defaultBaseURL + config.asrModel = CloudASRModelCatalog.alibabaFunASRRealtime + defaults.set(config.asrProviderId, forKey: Keys.asrProviderId) + defaults.set(config.asrBaseURL, forKey: Keys.asrBaseURL) + defaults.set(config.asrModel, forKey: Keys.asrModel) + } + // One-shot legacy migration: plaintext apiKey in UserDefaults → Keychain. _ = resolveAPIKey( defaults: defaults, @@ -311,26 +335,6 @@ public struct AppGroupConfiguration: Sendable, Equatable { config.modeId = "polish" defaults.set("polish", forKey: Keys.modeId) } - // DeepSeek is local-engine only — never a cloud picker choice. - if config.engineMode == "cloud", config.providerId == "deepseek" { - let openAI = LLMProvider.provider(id: "openai") - config.providerId = openAI.id - config.baseURL = openAI.defaultBaseURL - config.model = openAI.defaultModel - defaults.set(openAI.id, forKey: Keys.providerId) - defaults.set(openAI.defaultBaseURL, forKey: Keys.baseURL) - defaults.set(openAI.defaultModel, forKey: Keys.model) - } - if config.engineMode == "cloud", config.asrProviderId == "deepseek" { - let openAI = LLMProvider.provider(id: "openai") - config.asrProviderId = openAI.id - config.asrBaseURL = openAI.defaultBaseURL - config.asrModel = CloudASRModelCatalog.defaultModel(for: openAI.id) - defaults.set(openAI.id, forKey: Keys.asrProviderId) - defaults.set(openAI.defaultBaseURL, forKey: Keys.asrBaseURL) - defaults.set(openAI.defaultModel, forKey: Keys.asrModel) - } - return config } @@ -352,6 +356,7 @@ public struct AppGroupConfiguration: Sendable, Equatable { defaults.set(handednessPreference.rawValue, forKey: Keys.handednessPreference) defaults.set(cursorDragNavigationEnabled, forKey: Keys.cursorDragNavigationEnabled) defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity) + defaults.set(llmThinkingEnabled, forKey: Keys.llmThinkingEnabled) defaults.set(flowSkipAppSwitch, forKey: Keys.flowSkipAppSwitch) defaults.set(flowInactivityDuration.rawValue, forKey: Keys.flowInactivityDuration) defaults.set(localASRCustomLanguageModelEnabled, forKey: Keys.localASRCustomLanguageModelEnabled) diff --git a/OSGKeyboardShared/Models/CloudASRModels.swift b/OSGKeyboardShared/Models/CloudASRModels.swift index 1f1ba6b..751e3b3 100644 --- a/OSGKeyboardShared/Models/CloudASRModels.swift +++ b/OSGKeyboardShared/Models/CloudASRModels.swift @@ -10,10 +10,14 @@ import Foundation public enum CloudASRStrategy: String, Sendable, Equatable { /// 智谱 GLM-ASR — `hotwords` + optional `prompt`. case zhipuHotwords - /// 阿里百炼 Fun-ASR — managed `vocabulary_id` + context text. - case alibabaVocabulary - /// OpenAI / 小米 MiMo / 自定义端点 — `prompt` on transcription APIs. + /// 百炼 Fun-ASR Realtime — DashScope 经典 inference WebSocket 流式。 + case bailianStreaming + /// OpenAI / Groq / 硅基流动 / Whisper 等 — `prompt` on transcription APIs. case prompt + /// OpenRouter `/audio/transcriptions` — JSON body + base64 WAV (not multipart). + case openRouterJson + /// 火山引擎 SAUC 大模型流式 ASR(WebSocket + binary frame)。 + case volcengineStreaming /// Moonshot 托管 API 暂无音频转写;云端引擎回退端侧 ASR。 case localFallback } @@ -54,8 +58,23 @@ public enum CloudASRError: Error, LocalizedError, Sendable, Equatable { } public enum CloudASRModelCatalog { + /// Provider ids shown in the cloud ASR picker (explicit allowlist). + public static let selectableProviderIds: Set = [ + "openai", + "whisper", + "bailian", + "zhipu", + "groq", + "siliconflow", + "openrouter", + "mimo", + "volcengine", + "custom", + ] + /// Sync Fun-ASR Flash — base64 upload, ≤ 5 min, supports context + vocabulary. public static let alibabaFunASRFlash = "fun-asr-flash-2026-06-15" + public static let alibabaFunASRRealtime = "fun-asr-realtime" /// Must match the ASR model used at recognition time. public static let alibabaVocabularyTargetModel = alibabaFunASRFlash @@ -63,24 +82,38 @@ public enum CloudASRModelCatalog { public static let openAITranscribe = "gpt-4o-mini-transcribe" public static let openAIWhisper = "whisper-1" public static let mimoASR = "mimo-v2.5-asr" + public static let groqWhisper = "whisper-large-v3-turbo" + public static let siliconflowASR = "FunAudioLLM/SenseVoiceSmall" + public static let openrouterWhisper = "openai/whisper-large-v3-turbo" + public static let volcengineDefaultResourceID = "volc.seedasr.sauc.duration" + public static let volcengineEndpoint = "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async" + public static let bailianDefaultEndpoint = "wss://dashscope.aliyuncs.com/api-ws/v1/inference/" public static let alibabaAPIBase = "https://dashscope.aliyuncs.com/api/v1" public static let alibabaCustomizationPath = "/services/audio/asr/customization" public static let alibabaMultimodalPath = "/services/aigc/multimodal-generation/generation" public static let zhipuTranscriptionPath = "/audio/transcriptions" + public static func supportsCloudASRSelection(providerId: String) -> Bool { + selectableProviderIds.contains(providerId) + } + public static func strategy(for providerId: String) -> CloudASRStrategy { switch providerId { case "zhipu": return .zhipuHotwords - case "qwen": - return .alibabaVocabulary + case "bailian": + return .bailianStreaming case "moonshot": return .localFallback - case "openai", "mimo", "custom": + case "volcengine": + return .volcengineStreaming + case "openrouter": + return .openRouterJson + case "openai", "whisper", "mimo", "groq", "siliconflow", "custom": return .prompt default: - return .prompt + return .localFallback } } @@ -88,16 +121,36 @@ public enum CloudASRModelCatalog { switch providerId { case "zhipu": return zhipuGLMASR - case "qwen": - return alibabaFunASRFlash + case "bailian": + return alibabaFunASRRealtime + case "whisper": + return openAIWhisper case "mimo": return mimoASR + case "groq": + return groqWhisper + case "siliconflow": + return siliconflowASR + case "openrouter": + return openrouterWhisper + case "volcengine": + return volcengineDefaultResourceID case "openai", "custom": return openAITranscribe default: return openAITranscribe } } + + /// Whether the ASR settings card should expose a custom endpoint field. + public static func showsASREndpointField(for providerId: String) -> Bool { + switch strategy(for: providerId) { + case .prompt, .openRouterJson, .bailianStreaming: + return true + case .zhipuHotwords, .volcengineStreaming, .localFallback: + return false + } + } } extension LLMProvider { @@ -112,9 +165,9 @@ extension LLMProvider { /// Official hotwords / vocabulary APIs during cloud ASR (not prompt-only bias). public var supportsPersonalDictionaryCloudASR: Bool { switch cloudASRStrategy { - case .zhipuHotwords, .alibabaVocabulary: + case .zhipuHotwords: return true - case .prompt, .localFallback: + case .bailianStreaming, .prompt, .openRouterJson, .volcengineStreaming, .localFallback: return false } } diff --git a/OSGKeyboardShared/Models/FlowHandoffPolicy.swift b/OSGKeyboardShared/Models/FlowHandoffPolicy.swift new file mode 100644 index 0000000..2cc1a84 --- /dev/null +++ b/OSGKeyboardShared/Models/FlowHandoffPolicy.swift @@ -0,0 +1,137 @@ +// FlowHandoffPolicy.swift +// OSGKeyboard · Shared +// +// Pure decision helpers for keyboard → host handoff. Keeps "session still +// alive, ready contract briefly missing" from being treated as a cold start. + +import Foundation + +/// Action the keyboard should take when the user presses the mic. +public enum FlowMicPressAction: Equatable, Sendable { + case startRecording + /// Session is alive (or was very recently); poll for ready, then optionally record. + case waitForHostReady(recordWhenReady: Bool) + /// Host process is gone / no session — open `osgkeyboard://startflow`. + case openHostColdStart + case ignore +} + +/// Whether the host app should show the cold-start overlay for a `startflow`. +public enum FlowColdStartOverlayDecision: Equatable, Sendable { + /// Do not set handoff flags or show preparing/ready UI. + case silence + /// Show preparing and run the cold-start / recovery path. + case present +} + +public enum FlowHandoffPolicy { + /// Proactive keyboard auto-launch of the host is intentionally disabled. + /// Opening the host must be driven by an explicit mic press (or Live Activity). + public static let allowsProactiveHostAutoLaunch = false + + /// Samples of "host truly dead" required before a cold-start jump is allowed + /// from a non-press path. Mic press uses `shouldOpenHostColdStart` directly. + public static let coldStartDeadSampleThreshold = 2 + + /// True when the session contract still implies a living (or recoverable) + /// host — so a transient `ready=false` must wait, not jump. + public static func shouldTreatHostAsAlive( + sessionActive: Bool, + hostReachable: Bool, + hostStale: Bool, + withinReadyGrace: Bool + ) -> Bool { + if hostStale { return false } + guard sessionActive else { return false } + // Reachable heartbeat, or a recent ready sample, means the process is + // still ours — finalize races often look like hostNotReady for one frame. + if hostReachable || withinReadyGrace { return true } + // Session flag still valid and not past the zombie window: prefer wait. + return true + } + + /// Whether `osgkeyboard://startflow` is justified for the current host state. + public static func shouldOpenHostColdStart( + sessionActive: Bool, + hostReachable: Bool, + hostStale: Bool, + withinReadyGrace: Bool + ) -> Bool { + !shouldTreatHostAsAlive( + sessionActive: sessionActive, + hostReachable: hostReachable, + hostStale: hostStale, + withinReadyGrace: withinReadyGrace + ) + } + + /// Mic-press routing shared by the keyboard coordinator and unit tests. + public static func micPressAction( + availability: MicVoiceAvailability, + sessionActive: Bool, + hostReachable: Bool, + hostStale: Bool, + withinReadyGrace: Bool + ) -> FlowMicPressAction { + switch availability { + case .ready: + return .startRecording + case .recording, .processing: + return .ignore + case .unavailable(.missingAPIKey), + .unavailable(.noFullAccess), + .unavailable(.appGroupUnavailable): + // Caller surfaces the specific error UI. + return .ignore + case .unavailable(.preparingSession): + // Session is warming — never cold-start; wait then record. + return .waitForHostReady(recordWhenReady: true) + case .unavailable(.hostNotReady): + if shouldTreatHostAsAlive( + sessionActive: sessionActive, + hostReachable: hostReachable, + hostStale: hostStale, + withinReadyGrace: withinReadyGrace + ) { + return .waitForHostReady(recordWhenReady: true) + } + return .openHostColdStart + } + } + + /// Host-app gate: a `startflow` against an already-healthy (or busy) session + /// must not flash "Voice is ready". + public static func coldStartOverlayDecision( + sessionIsActive: Bool, + hostIsReady: Bool, + isUtteranceBusy: Bool + ) -> FlowColdStartOverlayDecision { + guard sessionIsActive else { return .present } + if hostIsReady || isUtteranceBusy { return .silence } + // Active but not ready and not busy — engine recovery may need UI. + return .present + } +} + +/// Counts consecutive "host truly dead" observations to ignore single-frame races. +public struct FlowColdStartDebouncer: Equatable, Sendable { + public private(set) var consecutiveDeadSamples: Int = 0 + + public init(consecutiveDeadSamples: Int = 0) { + self.consecutiveDeadSamples = consecutiveDeadSamples + } + + /// Returns true once enough consecutive dead samples have been seen. + public mutating func observe(hostTrulyDead: Bool) -> Bool { + if hostTrulyDead { + consecutiveDeadSamples += 1 + } else { + consecutiveDeadSamples = 0 + } + return consecutiveDeadSamples >= FlowHandoffPolicy.coldStartDeadSampleThreshold + } + + public mutating func reset() { + consecutiveDeadSamples = 0 + } +} diff --git a/OSGKeyboardShared/Models/HandednessPreference.swift b/OSGKeyboardShared/Models/HandednessPreference.swift index 1e6535c..8c5b273 100644 --- a/OSGKeyboardShared/Models/HandednessPreference.swift +++ b/OSGKeyboardShared/Models/HandednessPreference.swift @@ -2,7 +2,7 @@ // OSGKeyboard · Shared // // Which hand the user holds the phone with — controls bottom-row key order -// on the keyboard (delete ↔ return swap for right-handed use). +// on the keyboard (delete ↔ space swap for right-handed use). import Foundation @@ -19,7 +19,7 @@ public enum HandednessPreference: String, CaseIterable, Identifiable, Sendable, } } - /// Right-handed preference places return on the left and delete on the right. + /// Right-handed preference places space on the left and delete on the right. public var swapsActionKeys: Bool { self == .right } public static func fromStored(_ raw: String?) -> HandednessPreference { diff --git a/OSGKeyboardShared/Models/LLMProvider.swift b/OSGKeyboardShared/Models/LLMProvider.swift index a998b14..c015850 100644 --- a/OSGKeyboardShared/Models/LLMProvider.swift +++ b/OSGKeyboardShared/Models/LLMProvider.swift @@ -48,15 +48,21 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable { apiKeyURL: URL(string: "https://platform.openai.com/api-keys"), blurb: "GPT-4o mini · 多语言 · Multilingual" ), + .init( + id: "ark", + name: "火山方舟 Ark", + defaultBaseURL: "https://ark.cn-beijing.volces.com/api/v3", + defaultModel: "deepseek-v3-2-251201", + apiKeyURL: URL(string: "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey"), + blurb: "豆包 / DeepSeek · OpenAI 兼容 · OpenAI-compatible" + ), .init( id: "deepseek", name: "DeepSeek", defaultBaseURL: "https://api.deepseek.com/v1", defaultModel: "deepseek-v4-flash", apiKeyURL: URL(string: "https://platform.deepseek.com/api_keys"), - blurb: "deepseek-v4-flash · 本地引擎内置 · Local engine built-in", - // Local engine only — never shown in cloud-engine pickers. - isUserSelectable: false + blurb: "deepseek-v4-flash · 本地引擎可内置 · Local engine optional built-in" ), .init( id: "qwen", @@ -82,6 +88,30 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable { apiKeyURL: URL(string: "https://platform.moonshot.cn/console/api-keys"), blurb: "Kimi · 长上下文 · Long context" ), + .init( + id: "siliconflow", + name: "硅基流动 SiliconFlow", + defaultBaseURL: "https://api.siliconflow.cn/v1", + defaultModel: "Qwen/Qwen2.5-7B-Instruct", + apiKeyURL: URL(string: "https://cloud.siliconflow.cn/account/ak"), + blurb: "多模型聚合 · OpenAI 兼容 · OpenAI-compatible" + ), + .init( + id: "groq", + name: "Groq", + defaultBaseURL: "https://api.groq.com/openai/v1", + defaultModel: "llama-3.3-70b-versatile", + apiKeyURL: URL(string: "https://console.groq.com/keys"), + blurb: "超低延迟 LPU · Ultra-low latency" + ), + .init( + id: "minimax", + name: "MiniMax", + defaultBaseURL: "https://api.minimaxi.com/v1", + defaultModel: "MiniMax-M2.5", + apiKeyURL: URL(string: "https://platform.minimaxi.com/user-center/basic-information"), + blurb: "MiniMax-M2.5 · 中文优化 · Chinese-optimized" + ), .init( id: "mimo", name: "小米 MiMo", @@ -90,6 +120,106 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable { apiKeyURL: URL(string: "https://platform.xiaomimimo.com"), blurb: "mimo-v2.5 · 中文优化 · Chinese-optimized" ), + .init( + id: "openrouter", + name: "OpenRouter", + defaultBaseURL: "https://openrouter.ai/api/v1", + defaultModel: "qwen/qwen3-coder:free", + apiKeyURL: URL(string: "https://openrouter.ai/keys"), + blurb: "多模型路由 · Model routing · OpenAI-compatible" + ), + .init( + id: "gemini", + name: "Google Gemini", + defaultBaseURL: "https://generativelanguage.googleapis.com/v1beta/openai", + defaultModel: "gemini-2.5-flash", + apiKeyURL: URL(string: "https://aistudio.google.com/apikey"), + blurb: "Gemini 2.5 Flash · OpenAI 兼容端点" + ), + .init( + id: "anthropic", + name: "Anthropic Claude", + defaultBaseURL: "https://api.anthropic.com/v1", + defaultModel: "claude-sonnet-4-6", + apiKeyURL: URL(string: "https://console.anthropic.com/settings/keys"), + blurb: "Claude Sonnet · Messages API" + ), + .init( + id: "xai", + name: "xAI Grok", + defaultBaseURL: "https://api.x.ai/v1", + defaultModel: "grok-3-mini", + apiKeyURL: URL(string: "https://console.x.ai"), + blurb: "Grok · OpenAI 兼容 · OpenAI-compatible" + ), + .init( + id: "mistral", + name: "Mistral AI", + defaultBaseURL: "https://api.mistral.ai/v1", + defaultModel: "mistral-small-latest", + apiKeyURL: URL(string: "https://console.mistral.ai/api-keys"), + blurb: "Mistral Small · 欧洲托管 · EU-hosted" + ), + .init( + id: "cometapi", + name: "CometAPI", + defaultBaseURL: "https://api.cometapi.com/v1", + defaultModel: "gpt-4o", + apiKeyURL: URL(string: "https://api.cometapi.com"), + blurb: "多模型聚合 · OpenAI 兼容" + ), + .init( + id: "alibabaCoding", + name: "阿里 Coding Plan", + defaultBaseURL: "https://coding-intl.dashscope.aliyuncs.com/v1", + defaultModel: "qwen3-coder-plus", + apiKeyURL: URL(string: "https://dashscope.console.aliyun.com/apiKey"), + blurb: "通义 Coder · 代码润色 · Coding polish" + ), + .init( + id: "codingPlanX", + name: "CodingPlanX", + defaultBaseURL: "https://api.codingplanx.ai/v1", + defaultModel: "gpt-5-mini", + apiKeyURL: URL(string: "https://codingplanx.ai"), + blurb: "CodingPlanX · OpenAI 兼容" + ), + // MARK: - ASR-only presets (hidden from polish picker) + .init( + id: "volcengine", + name: "火山引擎 Volcengine", + defaultBaseURL: "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async", + defaultModel: "volc.seedasr.sauc.duration", + apiKeyURL: URL(string: "https://console.volcengine.com/speech"), + blurb: "流式大模型 ASR · API Key 填 appId:accessToken[:resourceId]", + isUserSelectable: false + ), + .init( + id: "bailian", + name: "百炼实时 ASR", + defaultBaseURL: "wss://dashscope.aliyuncs.com/api-ws/v1/inference/", + defaultModel: "fun-asr-realtime", + apiKeyURL: URL(string: "https://dashscope.console.aliyun.com/apiKey"), + blurb: "Fun-ASR Realtime · 百炼词表", + isUserSelectable: false + ), + .init( + id: "whisper", + name: "Whisper (OpenAI)", + defaultBaseURL: "https://api.openai.com/v1", + defaultModel: "whisper-1", + apiKeyURL: URL(string: "https://platform.openai.com/api-keys"), + blurb: "whisper-1 · 经典 Whisper 端点", + isUserSelectable: false + ), + .init( + id: "codex_oauth", + name: "Codex OAuth", + defaultBaseURL: "", + defaultModel: "gpt-5.3-codex-spark", + blurb: "ChatGPT Codex OAuth · 暂不支持", + isUserSelectable: false + ), .init( id: "custom", name: "Custom · 自定义", @@ -103,16 +233,13 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable { presets.first(where: { $0.id == id }) ?? .presets[0] } - /// Presets the user may pick in Settings / onboarding. DeepSeek is - /// excluded — it is wired exclusively to the local engine. + /// Presets the user may pick in Settings / onboarding. public static var userSelectablePresets: [LLMProvider] { presets.filter(\.isUserSelectable) } - /// Cloud ASR presets (excludes providers without a cloud transcription API). + /// Cloud ASR presets (explicit allowlist — polish-only providers excluded). public static var asrSelectablePresets: [LLMProvider] { - userSelectablePresets.filter { - CloudASRModelCatalog.strategy(for: $0.id) != .localFallback - } + presets.filter { CloudASRModelCatalog.supportsCloudASRSelection(providerId: $0.id) } } } diff --git a/OSGKeyboardShared/Models/ProviderConfig.swift b/OSGKeyboardShared/Models/ProviderConfig.swift index a47dca3..cb2ef26 100644 --- a/OSGKeyboardShared/Models/ProviderConfig.swift +++ b/OSGKeyboardShared/Models/ProviderConfig.swift @@ -111,7 +111,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { didSet { guard !isApplyingConfiguration, engineMode != configuration.engineMode else { return } configuration.engineMode = engineMode - applyEngineModeSideEffects() persistConfiguration(postConfigChanged: true) } } @@ -179,7 +178,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { } } /// Which hand the user holds the phone with — mirrors to the keyboard - /// extension so delete / return can swap on the bottom row. + /// extension so delete / space can swap on the bottom row. @Published public var handednessPreference: HandednessPreference { didSet { guard !isApplyingConfiguration, @@ -218,6 +217,17 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { } } + /// Enables provider-specific reasoning / thinking controls when the + /// selected polish LLM supports them. + @Published public var llmThinkingEnabled: Bool { + didSet { + guard !isApplyingConfiguration, + llmThinkingEnabled != configuration.llmThinkingEnabled else { return } + configuration.llmThinkingEnabled = llmThinkingEnabled + persistConfiguration(postConfigChanged: true) + } + } + /// When enabled, the host app tries to return to the source app after a cold-start handoff. @Published public var flowSkipAppSwitch: Bool { didSet { @@ -335,6 +345,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { handednessPreference = configuration.handednessPreference cursorDragNavigationEnabled = configuration.cursorDragNavigationEnabled polishIntensity = configuration.polishIntensity + llmThinkingEnabled = configuration.llmThinkingEnabled flowSkipAppSwitch = configuration.flowSkipAppSwitch flowInactivityDuration = configuration.flowInactivityDuration localASRCustomLanguageModelEnabled = configuration.localASRCustomLanguageModelEnabled @@ -347,15 +358,34 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { isApplyingConfiguration = false } - /// Keep cloud vs local provider choices isolated when the user - /// switches engines in Settings / onboarding. - private func applyEngineModeSideEffects() { - if engineMode == "cloud", providerId == "deepseek" { - apply(preset: LLMProvider.provider(id: "openai")) - } - if engineMode == "cloud", asrProviderId == "deepseek" { - applyAsr(preset: LLMProvider.provider(id: "openai")) - } + public func reset() { + isApplyingConfiguration = true + let polishPreset = LLMProvider.provider(id: AppGroupConfiguration.defaultPolishProviderId) + let asrPreset = LLMProvider.provider(id: AppGroupConfiguration.defaultCloudASRProviderId) + providerId = polishPreset.id + baseURL = polishPreset.defaultBaseURL + apiKey = "" + model = polishPreset.defaultModel + asrProviderId = asrPreset.id + asrBaseURL = asrPreset.defaultBaseURL + asrModel = CloudASRModelCatalog.defaultModel(for: asrPreset.id) + asrApiKey = "" + handednessPreference = .left + localASRCustomLanguageModelEnabled = true + llmThinkingEnabled = false + hasAcknowledgedCloudSharing = false + configuration.providerId = polishPreset.id + configuration.baseURL = polishPreset.defaultBaseURL + configuration.model = polishPreset.defaultModel + configuration.asrProviderId = asrPreset.id + configuration.asrBaseURL = asrPreset.defaultBaseURL + configuration.asrModel = CloudASRModelCatalog.defaultModel(for: asrPreset.id) + configuration.handednessPreference = .left + configuration.localASRCustomLanguageModelEnabled = true + configuration.llmThinkingEnabled = false + configuration.hasAcknowledgedCloudSharing = false + isApplyingConfiguration = false + persistConfiguration() } private func persistConfiguration(postConfigChanged: Bool = false) { @@ -401,6 +431,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { handednessPreference = fresh.handednessPreference cursorDragNavigationEnabled = fresh.cursorDragNavigationEnabled polishIntensity = fresh.polishIntensity + llmThinkingEnabled = fresh.llmThinkingEnabled flowSkipAppSwitch = fresh.flowSkipAppSwitch flowInactivityDuration = fresh.flowInactivityDuration localASRCustomLanguageModelEnabled = fresh.localASRCustomLanguageModelEnabled @@ -455,31 +486,4 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { isApplyingConfiguration = false persistConfiguration() } - - public func reset() { - isApplyingConfiguration = true - let preset = LLMProvider.provider(id: "openai") - providerId = preset.id - baseURL = preset.defaultBaseURL - apiKey = "" - model = preset.defaultModel - asrProviderId = preset.id - asrBaseURL = preset.defaultBaseURL - asrModel = CloudASRModelCatalog.defaultModel(for: preset.id) - asrApiKey = "" - handednessPreference = .left - localASRCustomLanguageModelEnabled = true - hasAcknowledgedCloudSharing = false - configuration.providerId = preset.id - configuration.baseURL = preset.defaultBaseURL - configuration.model = preset.defaultModel - configuration.asrProviderId = preset.id - configuration.asrBaseURL = preset.defaultBaseURL - configuration.asrModel = CloudASRModelCatalog.defaultModel(for: preset.id) - configuration.handednessPreference = .left - configuration.localASRCustomLanguageModelEnabled = true - configuration.hasAcknowledgedCloudSharing = false - isApplyingConfiguration = false - persistConfiguration() - } } diff --git a/OSGKeyboardShared/Models/ProviderLogo.swift b/OSGKeyboardShared/Models/ProviderLogo.swift index 1723e58..b763c09 100644 --- a/OSGKeyboardShared/Models/ProviderLogo.swift +++ b/OSGKeyboardShared/Models/ProviderLogo.swift @@ -10,12 +10,24 @@ public enum ProviderLogo { /// Asset name for the provider's logo, or `nil` when there is no bundled logo. public static func assetName(for providerId: String) -> String? { switch providerId { - case "openai": return "openai" + case "openai", "whisper": return "openai" case "deepseek": return "deepseek" - case "qwen": return "qwen" + case "qwen", "bailian", "alibabaCoding": return "qwen" case "moonshot": return "moonshot" case "zhipu": return "zhipu" case "mimo": return "mimo" + case "ark", "volcengine": return "ark" + case "siliconflow": return "siliconflow" + case "groq": return "groq" + case "minimax": return "minimax" + case "openrouter": return "openrouter" + case "gemini": return "gemini" + case "anthropic": return "anthropic" + case "xai": return "xai" + case "mistral": return "mistral" + case "cometapi": return "cometapi" + case "codingPlanX": return "codingplanx" + case "codex_oauth": return "openai" case "apple": return "apple" case "custom": return "custom" default: return nil diff --git a/OSGKeyboardShared/Models/SyncedAppSettingsV2.swift b/OSGKeyboardShared/Models/SyncedAppSettingsV2.swift index 643f12c..70b928f 100644 --- a/OSGKeyboardShared/Models/SyncedAppSettingsV2.swift +++ b/OSGKeyboardShared/Models/SyncedAppSettingsV2.swift @@ -27,6 +27,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable { public var handednessPreference: SyncedField public var cursorDragNavigationEnabled: SyncedField public var polishIntensity: SyncedField + public var llmThinkingEnabled: SyncedField public var flowSkipAppSwitch: SyncedField public var flowInactivityDuration: SyncedField @@ -47,6 +48,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable { handednessPreference: SyncedField, cursorDragNavigationEnabled: SyncedField, polishIntensity: SyncedField, + llmThinkingEnabled: SyncedField, flowSkipAppSwitch: SyncedField, flowInactivityDuration: SyncedField ) { @@ -66,6 +68,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable { self.handednessPreference = handednessPreference self.cursorDragNavigationEnabled = cursorDragNavigationEnabled self.polishIntensity = polishIntensity + self.llmThinkingEnabled = llmThinkingEnabled self.flowSkipAppSwitch = flowSkipAppSwitch self.flowInactivityDuration = flowInactivityDuration } @@ -87,6 +90,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable { case handednessPreference case cursorDragNavigationEnabled case polishIntensity + case llmThinkingEnabled case flowSkipAppSwitch case flowInactivityDuration } @@ -115,6 +119,10 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable { forKey: .cursorDragNavigationEnabled ) polishIntensity = try container.decode(SyncedField.self, forKey: .polishIntensity) + llmThinkingEnabled = try container.decodeIfPresent( + SyncedField.self, + forKey: .llmThinkingEnabled + ) ?? SyncedField(value: false, updatedAt: polishIntensity.updatedAt, deviceID: polishIntensity.deviceID) flowSkipAppSwitch = try container.decode(SyncedField.self, forKey: .flowSkipAppSwitch) flowInactivityDuration = try container.decode( SyncedField.self, @@ -161,6 +169,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable { handednessPreference.updatedAt, cursorDragNavigationEnabled.updatedAt, polishIntensity.updatedAt, + llmThinkingEnabled.updatedAt, flowSkipAppSwitch.updatedAt, flowInactivityDuration.updatedAt, ].max() ?? .distantPast @@ -197,6 +206,7 @@ public extension SyncedAppSettingsV2 { handednessPreference: field(configuration.handednessPreference), cursorDragNavigationEnabled: field(configuration.cursorDragNavigationEnabled), polishIntensity: field(configuration.polishIntensity), + llmThinkingEnabled: field(configuration.llmThinkingEnabled), flowSkipAppSwitch: field(configuration.flowSkipAppSwitch), flowInactivityDuration: field(configuration.flowInactivityDuration) ) @@ -225,6 +235,7 @@ public extension SyncedAppSettingsV2 { handednessPreference: field(legacy.handednessPreference), cursorDragNavigationEnabled: field(legacy.cursorDragNavigationEnabled), polishIntensity: field(legacy.polishIntensity), + llmThinkingEnabled: field(false), flowSkipAppSwitch: field(legacy.flowSkipAppSwitch), flowInactivityDuration: field(legacy.flowInactivityDuration) ) @@ -256,6 +267,7 @@ public extension SyncedAppSettingsV2 { remote: remote.cursorDragNavigationEnabled ), polishIntensity: .merge(local: local.polishIntensity, remote: remote.polishIntensity), + llmThinkingEnabled: .merge(local: local.llmThinkingEnabled, remote: remote.llmThinkingEnabled), flowSkipAppSwitch: .merge(local: local.flowSkipAppSwitch, remote: remote.flowSkipAppSwitch), flowInactivityDuration: .merge( local: local.flowInactivityDuration, @@ -280,6 +292,7 @@ public extension SyncedAppSettingsV2 { configuration.handednessPreference = handednessPreference.value configuration.cursorDragNavigationEnabled = cursorDragNavigationEnabled.value configuration.polishIntensity = polishIntensity.value + configuration.llmThinkingEnabled = llmThinkingEnabled.value configuration.flowSkipAppSwitch = flowSkipAppSwitch.value configuration.flowInactivityDuration = flowInactivityDuration.value } @@ -306,6 +319,7 @@ public extension SyncedAppSettingsV2 { patch(©.handednessPreference, value: configuration.handednessPreference) patch(©.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled) patch(©.polishIntensity, value: configuration.polishIntensity) + patch(©.llmThinkingEnabled, value: configuration.llmThinkingEnabled) patch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch) patch(©.flowInactivityDuration, value: configuration.flowInactivityDuration) return copy @@ -335,6 +349,7 @@ public extension SyncedAppSettingsV2 { touch(©.handednessPreference, value: configuration.handednessPreference) touch(©.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled) touch(©.polishIntensity, value: configuration.polishIntensity) + touch(©.llmThinkingEnabled, value: configuration.llmThinkingEnabled) touch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch) touch(©.flowInactivityDuration, value: configuration.flowInactivityDuration) return copy diff --git a/OSGKeyboardShared/Models/SyncedUsageStatisticsV2.swift b/OSGKeyboardShared/Models/SyncedUsageStatisticsV2.swift index a10937b..c2e949a 100644 --- a/OSGKeyboardShared/Models/SyncedUsageStatisticsV2.swift +++ b/OSGKeyboardShared/Models/SyncedUsageStatisticsV2.swift @@ -5,30 +5,78 @@ import Foundation +/// Local-calendar day key (`yyyy-MM-dd`) for daily usage buckets. String keys +/// sort lexicographically in chronological order, which keeps pruning and +/// range queries index-free. +public enum UsageStatisticsDayKey { + public static func key(for date: Date, calendar: Calendar = .current) -> String { + let c = calendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", c.year ?? 0, c.month ?? 0, c.day ?? 0) + } + + /// Drops buckets older than `days` so the synced blob stays small even + /// after months of use (the chart only ever needs the last 7 days). + public static func prune(_ daily: inout [String: Int], keepingDays days: Int, now: Date = Date(), calendar: Calendar = .current) { + guard let cutoff = calendar.date(byAdding: .day, value: -days, to: now) else { return } + let cutoffKey = key(for: cutoff, calendar: calendar) + daily = daily.filter { $0.key >= cutoffKey } + } +} + public struct UsageStatisticsDeviceSlice: Codable, Equatable, Sendable { public var updatedAt: Date public var dictationDurationSeconds: TimeInterval public var dictationCharacterCount: Int public var translationCharacterCount: Int + /// Grow-only per-day dictation character counts, keyed by local `yyyy-MM-dd`. + /// Powers the home page's 7-day chart; merged per-key with `max` (each device + /// only ever grows its own days) and summed across devices when aggregated. + public var dailyDictationCharacters: [String: Int] public init( updatedAt: Date = Date(), dictationDurationSeconds: TimeInterval = 0, dictationCharacterCount: Int = 0, - translationCharacterCount: Int = 0 + translationCharacterCount: Int = 0, + dailyDictationCharacters: [String: Int] = [:] ) { self.updatedAt = updatedAt self.dictationDurationSeconds = dictationDurationSeconds self.dictationCharacterCount = dictationCharacterCount self.translationCharacterCount = translationCharacterCount + self.dailyDictationCharacters = dailyDictationCharacters + } + + private enum CodingKeys: String, CodingKey { + case updatedAt + case dictationDurationSeconds + case dictationCharacterCount + case translationCharacterCount + case dailyDictationCharacters + } + + // Custom decode so slices written before the daily-buckets field still load + // (the missing key defaults to an empty map rather than failing the decode). + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + updatedAt = try container.decode(Date.self, forKey: .updatedAt) + dictationDurationSeconds = try container.decode(TimeInterval.self, forKey: .dictationDurationSeconds) + dictationCharacterCount = try container.decode(Int.self, forKey: .dictationCharacterCount) + translationCharacterCount = try container.decode(Int.self, forKey: .translationCharacterCount) + dailyDictationCharacters = try container.decodeIfPresent([String: Int].self, forKey: .dailyDictationCharacters) ?? [:] } public static func merge(local: UsageStatisticsDeviceSlice, remote: UsageStatisticsDeviceSlice) -> UsageStatisticsDeviceSlice { - UsageStatisticsDeviceSlice( + var mergedDaily = local.dailyDictationCharacters + for (day, value) in remote.dailyDictationCharacters { + mergedDaily[day] = max(mergedDaily[day] ?? 0, value) + } + return UsageStatisticsDeviceSlice( updatedAt: max(local.updatedAt, remote.updatedAt), dictationDurationSeconds: max(local.dictationDurationSeconds, remote.dictationDurationSeconds), dictationCharacterCount: max(local.dictationCharacterCount, remote.dictationCharacterCount), - translationCharacterCount: max(local.translationCharacterCount, remote.translationCharacterCount) + translationCharacterCount: max(local.translationCharacterCount, remote.translationCharacterCount), + dailyDictationCharacters: mergedDaily ) } @@ -75,6 +123,17 @@ public struct SyncedUsageStatisticsV2: Codable, Equatable, Sendable { ) } + /// Cross-device daily dictation characters (summed per `yyyy-MM-dd`). + public var aggregatedDailyDictationCharacters: [String: Int] { + var result: [String: Int] = [:] + for slice in devices.values { + for (day, value) in slice.dailyDictationCharacters { + result[day, default: 0] += value + } + } + return result + } + public static func merge(local: SyncedUsageStatisticsV2, remote: SyncedUsageStatisticsV2) -> SyncedUsageStatisticsV2 { var mergedDevices = local.devices for (deviceID, remoteSlice) in remote.devices { diff --git a/OSGKeyboardShared/Models/VolcengineASRFields.swift b/OSGKeyboardShared/Models/VolcengineASRFields.swift new file mode 100644 index 0000000..97b786a --- /dev/null +++ b/OSGKeyboardShared/Models/VolcengineASRFields.swift @@ -0,0 +1,73 @@ +// VolcengineASRFields.swift +// OSGKeyboard · Shared +// +// Parse / encode Volcengine SAUC credentials stored in the ASR API key field. + +import Foundation + +public struct VolcengineASRFields: Sendable, Equatable { + public var appID: String + public var accessToken: String + public var resourceID: String + + public init( + appID: String = "", + accessToken: String = "", + resourceID: String = CloudASRModelCatalog.defaultModel(for: "volcengine") + ) { + self.appID = appID + self.accessToken = accessToken + self.resourceID = resourceID + } + + public var encodedAPIKey: String { + let object = [ + "app_id": appID, + "access_token": accessToken, + "resource_id": resourceID, + ] + guard let data = try? JSONSerialization.data(withJSONObject: object), + let string = String(data: data, encoding: .utf8) else { + return [appID, accessToken, resourceID].joined(separator: ":") + } + return string + } + + public static func parse(apiKey: String, resourceFallback: String) -> VolcengineASRFields { + let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + var fields = VolcengineASRFields( + appID: "", + accessToken: "", + resourceID: resourceFallback.isEmpty + ? CloudASRModelCatalog.defaultModel(for: "volcengine") + : resourceFallback + ) + + if let data = trimmed.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + fields.appID = string(json, keys: ["app_id", "appId", "appid"]) ?? "" + fields.accessToken = string(json, keys: ["access_token", "accessToken", "token"]) ?? "" + fields.resourceID = string(json, keys: ["resource_id", "resourceId", "resource"]) ?? fields.resourceID + return fields + } + + let parts = trimmed + .components(separatedBy: CharacterSet(charactersIn: ":\n,")) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + if parts.indices.contains(0) { fields.appID = parts[0] } + if parts.indices.contains(1) { fields.accessToken = parts[1] } + if parts.indices.contains(2) { fields.resourceID = parts[2] } + return fields + } + + private static func string(_ json: [String: Any], keys: [String]) -> String? { + for key in keys { + if let value = json[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + } + } + return nil + } +} diff --git a/OSGKeyboardShared/Services/AnthropicLLMClient.swift b/OSGKeyboardShared/Services/AnthropicLLMClient.swift new file mode 100644 index 0000000..5355a1d --- /dev/null +++ b/OSGKeyboardShared/Services/AnthropicLLMClient.swift @@ -0,0 +1,69 @@ +// AnthropicLLMClient.swift +// OSGKeyboard · Shared +// +// Anthropic Messages API client for polish / translation prompts. + +import Foundation + +public struct AnthropicMessagesClient: LLMClient { + public let apiKey: String + public let model: String + public let session: URLSession + public let requestTimeout: TimeInterval = 15 + + public init( + apiKey: String, + model: String, + session: URLSession = .shared + ) { + self.apiKey = apiKey + self.model = model + self.session = session + } + + public func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String { + guard !apiKey.isEmpty else { throw LLMError.noAPIKey } + + let url = URL(string: "https://api.anthropic.com/v1/messages")! + let body: [String: Any] = [ + "model": model, + "max_tokens": 4_096, + "system": systemPrompt, + "messages": [ + ["role": "user", "content": text], + ], + ] + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + request.timeoutInterval = timeout ?? requestTimeout + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw LLMError.transport("non-HTTP response") + } + if !(200..<300).contains(http.statusCode) { + if http.statusCode == 429 { throw LLMError.rateLimited } + throw LLMError.http(status: http.statusCode) + } + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let content = json["content"] as? [[String: Any]], + let first = content.first, + let textBlock = first["text"] as? String else { + throw LLMError.decoding("anthropic content") + } + return textBlock.trimmingCharacters(in: .whitespacesAndNewlines) + } catch let err as LLMError { + throw err + } catch is CancellationError { + throw LLMError.cancelled + } catch { + throw LLMError.transport(String(describing: error)) + } + } +} diff --git a/OSGKeyboardShared/Services/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift index 5dd6ddd..8d6c021 100644 --- a/OSGKeyboardShared/Services/AppGroupStore.swift +++ b/OSGKeyboardShared/Services/AppGroupStore.swift @@ -66,6 +66,7 @@ public struct AppGroupStore: @unchecked Sendable { public var handednessPreference: HandednessPreference { configuration.handednessPreference } public var cursorDragNavigationEnabled: Bool { configuration.cursorDragNavigationEnabled } public var polishIntensity: PolishIntensity { configuration.polishIntensity } + public var llmThinkingEnabled: Bool { configuration.llmThinkingEnabled } public var isTranslationEffective: Bool { configuration.isTranslationEffective } public var isLocalEngine: Bool { configuration.isLocalEngine } public var polishModeForPipeline: PolishingService.PolishMode { configuration.polishModeForPipeline } @@ -87,21 +88,7 @@ public struct AppGroupStore: @unchecked Sendable { } public func setEngineMode(_ mode: String) { - mutateConfiguration { config in - config.engineMode = mode - if mode == "cloud", config.providerId == "deepseek" { - let openAI = LLMProvider.provider(id: "openai") - config.providerId = openAI.id - config.baseURL = openAI.defaultBaseURL - config.model = openAI.defaultModel - } - if mode == "cloud", config.asrProviderId == "deepseek" { - let openAI = LLMProvider.provider(id: "openai") - config.asrProviderId = openAI.id - config.asrBaseURL = openAI.defaultBaseURL - config.asrModel = CloudASRModelCatalog.defaultModel(for: openAI.id) - } - } + mutateConfiguration { $0.engineMode = mode } AppGroupConfigDarwin.postConfigChanged() } @@ -134,6 +121,11 @@ public struct AppGroupStore: @unchecked Sendable { mutateConfiguration { $0.polishIntensity = intensity } } + public func setLLMThinkingEnabled(_ enabled: Bool) { + mutateConfiguration { $0.llmThinkingEnabled = enabled } + AppGroupConfigDarwin.postConfigChanged() + } + public func setLocalASRCustomLanguageModelEnabled(_ enabled: Bool) { mutateConfiguration { $0.localASRCustomLanguageModelEnabled = enabled } } diff --git a/OSGKeyboardShared/Services/CloudASR/BailianRealtimeASRClient.swift b/OSGKeyboardShared/Services/CloudASR/BailianRealtimeASRClient.swift new file mode 100644 index 0000000..dc54258 --- /dev/null +++ b/OSGKeyboardShared/Services/CloudASR/BailianRealtimeASRClient.swift @@ -0,0 +1,434 @@ +// BailianRealtimeASRClient.swift +// OSGKeyboard · Shared +// +// Alibaba Cloud Bailian / DashScope realtime ASR over the classic inference +// WebSocket (`/api-ws/v1/inference`). Matches OpenLess' `bailian.rs` wire +// protocol: run-task → PCM binary frames → finish-task → result events. + +import Foundation + +struct BailianRealtimeASRClient: CloudASRTranscribing { + let apiKey: String + let endpoint: String + let model: String + let vocabularyID: String? + let session: URLSession + + /// 100 ms of 16 kHz / 16-bit / mono PCM. + private static let targetChunkBytes = 3_200 + private static let startTimeout: TimeInterval = 8 + private static let finalTimeout: TimeInterval = 12 + private static let sessionTimeout: TimeInterval = startTimeout + finalTimeout + 4 + + func prepare(dictionary: PersonalDictionary) async throws {} + + func transcribe( + samples: [Float], + sampleRate: Int, + locale: Locale, + dictionary: PersonalDictionary + ) async throws -> String { + guard !apiKey.isEmpty else { throw CloudASRError.noAPIKey } + guard sampleRate == 16_000 else { + throw CloudASRError.transport("Bailian realtime expects 16 kHz audio") + } + guard !samples.isEmpty else { throw CloudASRError.emptyTranscript } + + let url = try resolvedEndpointURL() + let pcm = Self.pcm16Data(samples: samples) + let taskID = UUID().uuidString.replacingOccurrences(of: "-", with: "") + let resolvedModel = model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? CloudASRModelCatalog.alibabaFunASRRealtime + : model.trimmingCharacters(in: .whitespacesAndNewlines) + + var request = URLRequest(url: url) + request.timeoutInterval = 8 + request.setValue( + "bearer \(apiKey.trimmingCharacters(in: .whitespacesAndNewlines))", + forHTTPHeaderField: "Authorization" + ) + + let wsTask = session.webSocketTask(with: request) + wsTask.resume() + + return try await withThrowingTaskGroup(of: String.self) { group in + let events = BailianEventStream(task: wsTask) + + group.addTask { + defer { events.cancel() } + return try await Self.runSession( + taskID: taskID, + model: resolvedModel, + pcm: pcm, + wsTask: wsTask, + events: events + ) + } + + group.addTask { + try await Task.sleep(nanoseconds: UInt64(Self.sessionTimeout * 1_000_000_000)) + events.cancel() + wsTask.cancel(with: .goingAway, reason: nil) + throw CloudASRError.transport("session timed out") + } + + guard let result = try await group.next() else { + throw CloudASRError.emptyTranscript + } + group.cancelAll() + return result.trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + /// Settings connection probe: handshake to `task-started` only. + /// + /// Reaching `task-started` proves endpoint + `Authorization` + model are + /// all valid — which is exactly what "validate connection" must check. + /// It deliberately sends NO audio: DashScope realtime rejects a short + /// silent probe with a `task-failed: emptyAudio`, which is a false + /// negative for a connectivity test. A real auth/quota/model failure + /// still arrives as `task-failed` before `task-started` and surfaces. + func probeConnection() async throws { + guard !apiKey.isEmpty else { throw CloudASRError.noAPIKey } + + let url = try resolvedEndpointURL() + let taskID = UUID().uuidString.replacingOccurrences(of: "-", with: "") + let resolvedModel = model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? CloudASRModelCatalog.alibabaFunASRRealtime + : model.trimmingCharacters(in: .whitespacesAndNewlines) + + var request = URLRequest(url: url) + request.timeoutInterval = 8 + request.setValue( + "bearer \(apiKey.trimmingCharacters(in: .whitespacesAndNewlines))", + forHTTPHeaderField: "Authorization" + ) + + let wsTask = session.webSocketTask(with: request) + wsTask.resume() + + try await withThrowingTaskGroup(of: Void.self) { group in + let events = BailianEventStream(task: wsTask) + + group.addTask { + defer { events.cancel() } + try await Self.sendText( + Self.runTaskMessage(taskID: taskID, model: resolvedModel, vocabularyID: nil), + task: wsTask + ) + try await events.waitForStarted(timeout: Self.startTimeout) + // Politely end the task; the connection is already proven. + try? await Self.sendText(Self.finishTaskMessage(taskID: taskID), task: wsTask) + } + + group.addTask { + try await Task.sleep(nanoseconds: UInt64(Self.startTimeout * 1_000_000_000)) + events.cancel() + wsTask.cancel(with: .goingAway, reason: nil) + throw CloudASRError.transport("connection probe timed out") + } + + _ = try await group.next() + group.cancelAll() + } + } + + private static func runSession( + taskID: String, + model: String, + pcm: Data, + wsTask: URLSessionWebSocketTask, + events: BailianEventStream + ) async throws -> String { + try await sendText( + runTaskMessage(taskID: taskID, model: model, vocabularyID: nil), + task: wsTask + ) + + try await events.waitForStarted(timeout: startTimeout) + + var offset = 0 + while offset < pcm.count { + let end = min(offset + targetChunkBytes, pcm.count) + try await sendBinary(pcm.subdata(in: offset.. URL { + let raw = endpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? CloudASRModelCatalog.bailianDefaultEndpoint + : endpoint.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: raw) else { throw CloudASRError.invalidURL } + return url + } + + private static func sendText(_ text: String, task: URLSessionWebSocketTask) async throws { + do { + try await task.send(.string(text)) + } catch { + throw CloudASRError.transport(error.localizedDescription) + } + } + + private static func sendBinary(_ data: Data, task: URLSessionWebSocketTask) async throws { + do { + try await task.send(.data(data)) + } catch { + throw CloudASRError.transport(error.localizedDescription) + } + } + + private static func pcm16Data(samples: [Float]) -> Data { + var data = Data() + data.reserveCapacity(samples.count * 2) + for sample in samples { + let scaled = sample * 32_767.0 + let clipped = Swift.max(-32_768.0, Swift.min(32_767.0, scaled)) + var littleEndian = Int16(clipped.rounded()).littleEndian + withUnsafeBytes(of: &littleEndian) { data.append(contentsOf: $0) } + } + return data + } + + /// Overlap-aware join to avoid cumulative duplicate text from interim replays. + static func mergeSegments(_ segments: [String]) -> String { + var result = "" + for segment in segments { + if result.isEmpty { + result = segment + continue + } + let resultChars = Array(result) + let segmentChars = Array(segment) + let maxOverlap = min(resultChars.count, segmentChars.count) + var overlap = 0 + if maxOverlap >= 2 { + for length in stride(from: maxOverlap, through: 2, by: -1) { + let tail = resultChars.suffix(length) + let head = segmentChars.prefix(length) + if tail.elementsEqual(head) { + overlap = length + break + } + } + } + result.append(contentsOf: segmentChars.dropFirst(overlap)) + } + return result + } + + static func runTaskMessage(taskID: String, model: String, vocabularyID: String?) -> String { + var parameters: [String: Any] = [ + "sample_rate": 16_000, + "format": "pcm", + ] + if let vocabularyID = vocabularyID?.trimmingCharacters(in: .whitespacesAndNewlines), + !vocabularyID.isEmpty { + parameters["vocabulary_id"] = vocabularyID + } + let body: [String: Any] = [ + "header": [ + "action": "run-task", + "task_id": taskID, + "streaming": "duplex", + ], + "payload": [ + "task_group": "audio", + "task": "asr", + "function": "recognition", + "model": model, + "parameters": parameters, + "input": [:] as [String: Any], + ], + ] + guard let data = try? JSONSerialization.data(withJSONObject: body), + let json = String(data: data, encoding: .utf8) else { + return "{}" + } + return json + } + + static func finishTaskMessage(taskID: String) -> String { + let body: [String: Any] = [ + "header": [ + "action": "finish-task", + "task_id": taskID, + "streaming": "duplex", + ], + "payload": ["input": [:] as [String: Any]], + ] + guard let data = try? JSONSerialization.data(withJSONObject: body), + let json = String(data: data, encoding: .utf8) else { + return "{}" + } + return json + } +} + +// MARK: - Concurrent read loop + +private final class BailianEventStream: @unchecked Sendable { + private let task: URLSessionWebSocketTask + private let lock = NSLock() + private var started = false + private var finalText: String? + private var failure: Error? + private var readTask: Task? + + init(task: URLSessionWebSocketTask) { + self.task = task + readTask = Task { [weak self] in + await self?.readLoop() + } + } + + func cancel() { + readTask?.cancel() + task.cancel(with: .goingAway, reason: nil) + } + + func waitForStarted(timeout: TimeInterval) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let failure = snapshotFailure() { throw failure } + if snapshotStarted() { return } + try await Task.sleep(nanoseconds: 20_000_000) + } + cancel() + throw CloudASRError.transport("task-started timed out") + } + + func waitForFinalText(timeout: TimeInterval) async throws -> String { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if let failure = snapshotFailure() { throw failure } + if let text = snapshotFinalText() { return text } + try await Task.sleep(nanoseconds: 20_000_000) + } + cancel() + throw CloudASRError.transport("final result timed out") + } + + private func snapshotStarted() -> Bool { + lock.lock() + defer { lock.unlock() } + return started + } + + private func snapshotFinalText() -> String? { + lock.lock() + defer { lock.unlock() } + return finalText + } + + private func snapshotFailure() -> Error? { + lock.lock() + defer { lock.unlock() } + return failure + } + + private func readLoop() async { + var finalSegments: [Int64: String] = [:] + var partialSegments: [Int64: String] = [:] + var lastResultText = "" + + while !Task.isCancelled { + let message: URLSessionWebSocketTask.Message + do { + message = try await task.receive() + } catch { + publishFailure(CloudASRError.transport(error.localizedDescription)) + return + } + + let text: String + switch message { + case .string(let value): + text = value + case .data(let data): + text = String(data: data, encoding: .utf8) ?? "" + @unknown default: + continue + } + guard !text.isEmpty else { continue } + + guard let json = try? JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any], + let header = json["header"] as? [String: Any] else { + continue + } + let event = header["event"] as? String ?? "" + + switch event { + case "task-started": + publishStarted() + case "result-generated": + guard let payload = json["payload"] as? [String: Any], + let output = payload["output"] as? [String: Any], + let sentenceObj = output["sentence"] as? [String: Any] else { + continue + } + if sentenceObj["heartbeat"] as? Bool == true { continue } + guard let rawText = sentenceObj["text"] as? String else { continue } + let trimmed = rawText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + + lastResultText = trimmed + let sentenceID = sentenceObj["sentence_id"] as? Int64 ?? 0 + let sentenceEndValue = sentenceObj["sentence_end"] + let sentenceEnd = sentenceEndValue as? Bool ?? false + let endTime = sentenceObj["end_time"] as? Int64 ?? 0 + let isFinal = sentenceEndValue != nil ? sentenceEnd : endTime > 0 + + if isFinal { + finalSegments[sentenceID] = trimmed + partialSegments.removeValue(forKey: sentenceID) + } else { + partialSegments[sentenceID] = trimmed + } + case "task-finished": + if finalSegments.isEmpty { + publishFinal(lastResultText) + } else { + let ordered = finalSegments.keys.sorted().compactMap { finalSegments[$0] } + publishFinal(BailianRealtimeASRClient.mergeSegments(ordered)) + } + return + case "task-failed": + let message = header["error_message"] as? String ?? "task failed" + publishFailure(CloudASRError.transport(message)) + return + default: + break + } + } + } + + private func publishStarted() { + lock.lock() + started = true + lock.unlock() + } + + private func publishFinal(_ text: String) { + lock.lock() + finalText = text + lock.unlock() + } + + private func publishFailure(_ error: Error) { + lock.lock() + failure = error + lock.unlock() + cancel() + } +} diff --git a/OSGKeyboardShared/Services/CloudASR/CloudASRClients.swift b/OSGKeyboardShared/Services/CloudASR/CloudASRClients.swift index 779eecb..81e55af 100644 --- a/OSGKeyboardShared/Services/CloudASR/CloudASRClients.swift +++ b/OSGKeyboardShared/Services/CloudASR/CloudASRClients.swift @@ -13,6 +13,28 @@ public protocol CloudASRTranscribing: Sendable { locale: Locale, dictionary: PersonalDictionary ) async throws -> String + + /// Settings "validate connection" probe. Verifies transport + auth only. + func probeConnection() async throws +} + +extension CloudASRTranscribing { + /// Default probe: transcribe ~1 s of near-silence. An empty transcript + /// counts as success — HTTP/streaming providers only need to prove that + /// transport + auth work. Providers whose service rejects silent/short + /// audio (e.g. DashScope realtime returns `emptyAudio`) override this. + public func probeConnection() async throws { + do { + _ = try await transcribe( + samples: [Float](repeating: 0.01, count: 16_000), + sampleRate: 16_000, + locale: Locale(identifier: "zh-CN"), + dictionary: .empty + ) + } catch CloudASRError.emptyTranscript { + return + } + } } public enum CloudASRClientFactory { @@ -29,11 +51,12 @@ public enum CloudASRClientFactory { model: asrModel, session: session ) - case .alibabaVocabulary: - return AlibabaFunASRClient( + case .bailianStreaming: + return BailianRealtimeASRClient( apiKey: store.asrApiKey, + endpoint: store.asrBaseURL, model: asrModel, - persistence: store.cloudASRPersistence, + vocabularyID: nil, session: session ) case .prompt: @@ -44,6 +67,22 @@ public enum CloudASRClientFactory { model: asrModel, session: session ) + case .openRouterJson: + return PromptCloudASRClient( + providerId: providerId, + baseURL: store.asrBaseURL, + apiKey: store.asrApiKey, + model: asrModel, + session: session, + requestFormat: .openRouterJson + ) + case .volcengineStreaming: + return VolcengineCloudASRClient( + apiKey: store.asrApiKey, + endpoint: store.asrBaseURL, + resourceID: asrModel, + session: session + ) case .localFallback: return UnsupportedCloudASRClient(providerId: providerId) } @@ -151,25 +190,14 @@ struct ZhipuCloudASRClient: CloudASRTranscribing { } } -// MARK: - Alibaba Fun-ASR Flash (vocabulary_id + context) +// MARK: - Alibaba Fun-ASR Flash (HTTP sync, context text bias) -/// `UserDefaults` is not `Sendable`; we only touch `persistence` on the -/// actor-isolated cloud ASR path, same as the previous `AppGroupStore` holder. -struct AlibabaFunASRClient: CloudASRTranscribing, @unchecked Sendable { +struct AlibabaFunASRClient: CloudASRTranscribing { let apiKey: String let model: String - let persistence: UserDefaults let session: URLSession - func prepare(dictionary: PersonalDictionary) async throws { - _ = try await AlibabaVocabularySync.ensureVocabularyID( - dictionary: dictionary, - apiKey: apiKey, - targetModel: CloudASRModelCatalog.alibabaVocabularyTargetModel, - defaults: persistence, - session: session - ) - } + func prepare(dictionary: PersonalDictionary) async throws {} func transcribe( samples: [Float], @@ -179,14 +207,6 @@ struct AlibabaFunASRClient: CloudASRTranscribing, @unchecked Sendable { ) async throws -> String { guard !apiKey.isEmpty else { throw CloudASRError.noAPIKey } - let vocabularyID = try await AlibabaVocabularySync.ensureVocabularyID( - dictionary: dictionary, - apiKey: apiKey, - targetModel: CloudASRModelCatalog.alibabaVocabularyTargetModel, - defaults: persistence, - session: session - ) - let dataURI = PCMSampleWavEncoder.dataURI(samples: samples, sampleRate: sampleRate) let urlString = CloudASRModelCatalog.alibabaAPIBase + CloudASRModelCatalog.alibabaMultimodalPath guard let url = URL(string: urlString) else { throw CloudASRError.invalidURL } @@ -211,13 +231,10 @@ struct AlibabaFunASRClient: CloudASRTranscribing, @unchecked Sendable { ], ]) - var parameters: [String: Any] = [ + let parameters: [String: Any] = [ "format": "wav", "sample_rate": "\(sampleRate)", ] - if let vocabularyID, !vocabularyID.isEmpty { - parameters["vocabulary_id"] = vocabularyID - } let body: [String: Any] = [ "model": model, @@ -235,11 +252,11 @@ struct AlibabaFunASRClient: CloudASRTranscribing, @unchecked Sendable { let (data, response) = try await session.data(for: request) try ZhipuCloudASRClient.validateHTTP(response: response, data: data) - guard let text = Self.parseText(from: data)?.trimmingCharacters(in: .whitespacesAndNewlines), - !text.isEmpty else { - throw CloudASRError.emptyTranscript + if let text = Self.parseText(from: data)?.trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty { + return text } - return text + return "" } private static func parseText(from data: Data) -> String? { @@ -256,7 +273,13 @@ struct AlibabaFunASRClient: CloudASRTranscribing, @unchecked Sendable { } } -// MARK: - Prompt-biased transcription (OpenAI / MiMo / custom) +// MARK: - Prompt-biased transcription (OpenAI / MiMo / Groq / custom) + +enum PromptCloudASRRequestFormat: Sendable { + case multipart + /// OpenRouter expects JSON `{ model, input_audio: { data, format } }`. + case openRouterJson +} struct PromptCloudASRClient: CloudASRTranscribing { let providerId: String @@ -264,6 +287,26 @@ struct PromptCloudASRClient: CloudASRTranscribing { let apiKey: String let model: String let session: URLSession + var requestFormat: PromptCloudASRRequestFormat = .multipart + + /// Groq / OpenRouter batch uploads cap around 30 s per request. + private static let whisperCompatibleMaxDurationSeconds: TimeInterval = 30 + + init( + providerId: String, + baseURL: String, + apiKey: String, + model: String, + session: URLSession, + requestFormat: PromptCloudASRRequestFormat = .multipart + ) { + self.providerId = providerId + self.baseURL = baseURL + self.apiKey = apiKey + self.model = model + self.session = session + self.requestFormat = requestFormat + } func prepare(dictionary: PersonalDictionary) async throws {} @@ -281,6 +324,13 @@ struct PromptCloudASRClient: CloudASRTranscribing { dictionary: dictionary ) } + if requestFormat == .openRouterJson { + return try await transcribeOpenRouterJSON( + samples: samples, + sampleRate: sampleRate, + dictionary: dictionary + ) + } return try await transcribeOpenAIStyle( samples: samples, sampleRate: sampleRate, @@ -288,11 +338,21 @@ struct PromptCloudASRClient: CloudASRTranscribing { ) } + private func enforceWhisperDuration(samples: [Float], sampleRate: Int) throws { + let duration = Double(samples.count) / Double(sampleRate) + guard duration <= Self.whisperCompatibleMaxDurationSeconds else { + throw CloudASRError.audioTooLong + } + } + private func transcribeOpenAIStyle( samples: [Float], sampleRate: Int, dictionary: PersonalDictionary ) async throws -> String { + if providerId == "groq" || providerId == "openai" || providerId == "custom" { + try enforceWhisperDuration(samples: samples, sampleRate: sampleRate) + } let wav = PCMSampleWavEncoder.encode(samples: samples, sampleRate: sampleRate) let trimmedBase = baseURL.hasSuffix("/") ? String(baseURL.dropLast()) : baseURL let urlString = "\(trimmedBase)/audio/transcriptions" @@ -335,6 +395,45 @@ struct PromptCloudASRClient: CloudASRTranscribing { return text } + private func transcribeOpenRouterJSON( + samples: [Float], + sampleRate: Int, + dictionary: PersonalDictionary + ) async throws -> String { + try enforceWhisperDuration(samples: samples, sampleRate: sampleRate) + let wav = PCMSampleWavEncoder.encode(samples: samples, sampleRate: sampleRate) + let trimmedBase = baseURL.hasSuffix("/") ? String(baseURL.dropLast()) : baseURL + let urlString = "\(trimmedBase)/audio/transcriptions" + guard let url = URL(string: urlString) else { throw CloudASRError.invalidURL } + + var body: [String: Any] = [ + "model": model, + "input_audio": [ + "data": wav.base64EncodedString(), + "format": "wav", + ], + ] + let prompt = dictionary.asrPromptBias(maxCharacters: 600) + if !prompt.isEmpty { + body["prompt"] = prompt + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.httpBody = try JSONSerialization.data(withJSONObject: body) + request.timeoutInterval = 90 + + let (data, response) = try await session.data(for: request) + try ZhipuCloudASRClient.validateHTTP(response: response, data: data) + guard let text = Self.parseOpenAIText(from: data)?.trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty else { + throw CloudASRError.emptyTranscript + } + return text + } + private func transcribeMiMo( samples: [Float], sampleRate: Int, diff --git a/OSGKeyboardShared/Services/CloudASR/CloudASRConnectionCheck.swift b/OSGKeyboardShared/Services/CloudASR/CloudASRConnectionCheck.swift new file mode 100644 index 0000000..9879f0a --- /dev/null +++ b/OSGKeyboardShared/Services/CloudASR/CloudASRConnectionCheck.swift @@ -0,0 +1,19 @@ +// CloudASRConnectionCheck.swift +// OSGKeyboard · Shared +// +// Settings "validate connection" probe shared by iOS and macOS. + +import Foundation + +public enum CloudASRConnectionCheck { + /// Verifies the active cloud ASR client can connect + authenticate. + /// + /// Each backend decides how to probe (see `CloudASRTranscribing`): + /// HTTP/batch providers transcribe a short silence clip and treat an + /// empty transcript as success; DashScope realtime only handshakes to + /// `task-started` (pushing fake audio makes it fail with `emptyAudio`). + public static func validate(store: any ConfigurationStore) async throws { + let client = CloudASRClientFactory.make(store: store) + try await client.probeConnection() + } +} diff --git a/OSGKeyboardShared/Services/CloudASR/VolcengineCloudASRClient.swift b/OSGKeyboardShared/Services/CloudASR/VolcengineCloudASRClient.swift new file mode 100644 index 0000000..e10b2fd --- /dev/null +++ b/OSGKeyboardShared/Services/CloudASR/VolcengineCloudASRClient.swift @@ -0,0 +1,400 @@ +// VolcengineCloudASRClient.swift +// OSGKeyboard · Shared +// +// Volcengine SAUC bigmodel ASR client. The service uses a WebSocket with a +// small custom binary frame wrapper; this file keeps that protocol isolated +// from the HTTP-style cloud ASR clients. + +import Foundation + +struct VolcengineCloudASRClient: CloudASRTranscribing { + let apiKey: String + let endpoint: String + let resourceID: String + let session: URLSession + + private static let targetChunkBytes = 6_400 // 200 ms @ 16 kHz, 16-bit, mono. + private static let finalTimeout: TimeInterval = 12 + private static let hotwordCap = 80 + + func prepare(dictionary: PersonalDictionary) async throws {} + + func transcribe( + samples: [Float], + sampleRate: Int, + locale: Locale, + dictionary: PersonalDictionary + ) async throws -> String { + guard !samples.isEmpty else { throw CloudASRError.emptyTranscript } + let credentials = try VolcengineCredentials.parse( + apiKey: apiKey, + fallbackResourceID: resolvedResourceID + ) + let url = try resolvedEndpointURL() + let pcm = Self.pcm16Data(samples: samples) + let connectID = UUID().uuidString + + var request = URLRequest(url: url) + request.timeoutInterval = 8 + request.setValue(credentials.appID, forHTTPHeaderField: "X-Api-App-Key") + request.setValue(credentials.accessToken, forHTTPHeaderField: "X-Api-Access-Key") + request.setValue(credentials.resourceID, forHTTPHeaderField: "X-Api-Resource-Id") + request.setValue(connectID, forHTTPHeaderField: "X-Api-Connect-Id") + + let task = session.webSocketTask(with: request) + task.resume() + defer { + task.cancel(with: .normalClosure, reason: nil) + } + + let firstPayload = try Self.firstFramePayload(connectID: connectID, dictionary: dictionary) + try await send( + VolcengineFrame.build( + messageType: .fullClientRequest, + flags: .positiveSequence, + serialization: .json, + payload: firstPayload, + sequence: 1 + ), + task: task + ) + + var sequence = 2 + var offset = 0 + while offset < pcm.count { + let end = min(offset + Self.targetChunkBytes, pcm.count) + try await send( + VolcengineFrame.build( + messageType: .audioOnlyRequest, + flags: .positiveSequence, + serialization: .none, + payload: pcm.subdata(in: offset.. URL { + let raw = endpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? CloudASRModelCatalog.volcengineEndpoint + : endpoint.trimmingCharacters(in: .whitespacesAndNewlines) + guard let url = URL(string: raw) else { throw CloudASRError.invalidURL } + return url + } + + private func send(_ data: Data, task: URLSessionWebSocketTask) async throws { + do { + try await task.send(.data(data)) + } catch { + throw CloudASRError.transport(error.localizedDescription) + } + } + + private func receiveFinalText(task: URLSessionWebSocketTask) async throws -> String { + try await withThrowingTaskGroup(of: String.self) { group in + group.addTask { + var lastPartial = "" + while true { + let message = try await task.receive() + let data: Data + switch message { + case .data(let payload): + data = payload + case .string(let string): + data = Data(string.utf8) + @unknown default: + continue + } + + guard let frame = VolcengineFrame.parse(data) else { continue } + if frame.messageType == .errorMessage { + let body = String(data: frame.payload, encoding: .utf8) ?? "" + let code = frame.errorCode ?? 0 + throw CloudASRError.transport("ASR error \(code): \(body)") + } + guard frame.messageType == .fullServerResponse else { continue } + let parsedText = Self.text(from: frame.payload) + if !parsedText.isEmpty { + lastPartial = parsedText + } + if frame.isFinal { + return parsedText.isEmpty ? lastPartial : parsedText + } + } + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(Self.finalTimeout * 1_000_000_000)) + throw CloudASRError.transport("Volcengine final result timed out") + } + let result = try await group.next()! + group.cancelAll() + return result + } + } + + private static func firstFramePayload( + connectID: String, + dictionary: PersonalDictionary + ) throws -> Data { + var request: [String: Any] = [ + "model_name": "bigmodel", + "enable_itn": true, + "enable_punc": true, + "show_utterances": true, + "enable_speaker_info": true, + ] + if let context = hotwordContext(dictionary: dictionary) { + request["context"] = context + } + + let payload: [String: Any] = [ + "user": ["uid": connectID], + "audio": [ + "format": "pcm", + "rate": 16_000, + "bits": 16, + "channel": 1, + "codec": "raw", + ], + "request": request, + ] + return try JSONSerialization.data(withJSONObject: payload) + } + + private static func hotwordContext(dictionary: PersonalDictionary) -> String? { + var seen: [String] = [] + for word in dictionary.asrHotwords() { + let trimmed = word.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + guard !seen.contains(where: { $0.caseInsensitiveCompare(trimmed) == .orderedSame }) else { + continue + } + seen.append(trimmed) + if seen.count >= hotwordCap { break } + } + guard !seen.isEmpty else { return nil } + let words = seen.map { ["word": $0] } + guard let data = try? JSONSerialization.data(withJSONObject: ["hotwords": words]) else { + return nil + } + return String(data: data, encoding: .utf8) + } + + private static func pcm16Data(samples: [Float]) -> Data { + var data = Data() + data.reserveCapacity(samples.count * 2) + for sample in samples { + let scaled = sample * 32_767.0 + let clipped = Swift.max(-32_768.0, Swift.min(32_767.0, scaled)) + var littleEndian = Int16(clipped.rounded()).littleEndian + withUnsafeBytes(of: &littleEndian) { data.append(contentsOf: $0) } + } + return data + } + + private static func text(from payload: Data) -> String { + guard let json = try? JSONSerialization.jsonObject(with: payload) as? [String: Any], + let result = normalizedResult(from: json) else { + return "" + } + + if let utterances = result["utterances"] as? [[String: Any]], !utterances.isEmpty { + let pieces = utterances.compactMap { $0["text"] as? String } + let joined = pieces.joined() + if !joined.isEmpty { return joined } + } + return result["text"] as? String ?? "" + } + + private static func normalizedResult(from json: [String: Any]) -> [String: Any]? { + if let result = json["result"] as? [String: Any] { + return result + } + if let results = json["result"] as? [[String: Any]] { + return results.first + } + if json["text"] as? String != nil { + return json + } + return nil + } +} + +private struct VolcengineCredentials { + let appID: String + let accessToken: String + let resourceID: String + + static func parse(apiKey: String, fallbackResourceID: String) throws -> VolcengineCredentials { + let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw CloudASRError.noAPIKey } + + if let data = trimmed.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + let appID = string(json, keys: ["app_id", "appId", "appid"]) + let token = string(json, keys: ["access_token", "accessToken", "token"]) + let resourceID = string(json, keys: ["resource_id", "resourceId", "resource"]) + ?? fallbackResourceID + guard let appID, let token, !resourceID.isEmpty else { throw CloudASRError.noAPIKey } + return VolcengineCredentials(appID: appID, accessToken: token, resourceID: resourceID) + } + + let separators = CharacterSet(charactersIn: ":\n,") + let parts = trimmed + .components(separatedBy: separators) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard parts.count >= 2 else { throw CloudASRError.noAPIKey } + let resourceID = parts.count >= 3 ? parts[2] : fallbackResourceID + return VolcengineCredentials(appID: parts[0], accessToken: parts[1], resourceID: resourceID) + } + + private static func string(_ json: [String: Any], keys: [String]) -> String? { + for key in keys { + if let value = json[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + } + } + return nil + } +} + +private enum VolcengineMessageType: UInt8 { + case fullClientRequest = 0b0001 + case audioOnlyRequest = 0b0010 + case fullServerResponse = 0b1001 + case errorMessage = 0b1111 +} + +private enum VolcengineFlags: UInt8 { + case none = 0b0000 + case positiveSequence = 0b0001 + case lastPacket = 0b0010 + case negativeSequence = 0b0011 +} + +private enum VolcengineSerialization: UInt8 { + case none = 0b0000 + case json = 0b0001 +} + +private struct VolcengineFrame { + let messageType: VolcengineMessageType? + let flags: UInt8 + let sequence: Int32? + let errorCode: UInt32? + let payload: Data + + var isFinal: Bool { + flags == VolcengineFlags.lastPacket.rawValue + || flags == VolcengineFlags.negativeSequence.rawValue + || (sequence ?? 0) < 0 + } + + static func build( + messageType: VolcengineMessageType, + flags: VolcengineFlags, + serialization: VolcengineSerialization, + payload: Data, + sequence: Int32? + ) -> Data { + var data = Data() + data.append(0x11) + data.append((messageType.rawValue << 4) | flags.rawValue) + data.append(serialization.rawValue << 4) + data.append(0x00) + + if flags == .positiveSequence || flags == .negativeSequence, let sequence { + data.appendBE32(UInt32(bitPattern: sequence)) + } + data.appendBE32(UInt32(payload.count)) + data.append(payload) + return data + } + + static func parse(_ data: Data) -> VolcengineFrame? { + guard data.count >= 8 else { return nil } + let bytes = [UInt8](data) + let headerSize = Int(bytes[0] & 0x0F) * 4 + guard headerSize >= 4, data.count >= headerSize + 4 else { return nil } + + let typeRaw = (bytes[1] >> 4) & 0x0F + let messageType = VolcengineMessageType(rawValue: typeRaw) + let flags = bytes[1] & 0x0F + let compression = bytes[2] & 0x0F + guard compression == 0 else { return nil } + + var offset = headerSize + var sequence: Int32? + if flags == VolcengineFlags.positiveSequence.rawValue + || flags == VolcengineFlags.negativeSequence.rawValue { + guard let value = data.readBE32(at: offset) else { return nil } + sequence = Int32(bitPattern: value) + offset += 4 + } + + if messageType == .errorMessage { + guard let code = data.readBE32(at: offset), + let size = data.readBE32(at: offset + 4) else { return nil } + offset += 8 + guard data.count >= offset + Int(size) else { return nil } + return VolcengineFrame( + messageType: messageType, + flags: flags, + sequence: sequence, + errorCode: code, + payload: data.subdata(in: offset..<(offset + Int(size))) + ) + } + + guard let size = data.readBE32(at: offset) else { return nil } + offset += 4 + guard data.count >= offset + Int(size) else { return nil } + return VolcengineFrame( + messageType: messageType, + flags: flags, + sequence: sequence, + errorCode: nil, + payload: data.subdata(in: offset..<(offset + Int(size))) + ) + } +} + +private extension Data { + mutating func appendBE32(_ value: UInt32) { + var bigEndian = value.bigEndian + Swift.withUnsafeBytes(of: &bigEndian) { append(contentsOf: $0) } + } + + func readBE32(at offset: Int) -> UInt32? { + guard count >= offset + 4 else { return nil } + return self[offset..<(offset + 4)].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) } + } +} diff --git a/OSGKeyboardShared/Services/ICloudSync/AppCloudSync.swift b/OSGKeyboardShared/Services/ICloudSync/AppCloudSync.swift index f8ef8ad..250110e 100644 --- a/OSGKeyboardShared/Services/ICloudSync/AppCloudSync.swift +++ b/OSGKeyboardShared/Services/ICloudSync/AppCloudSync.swift @@ -93,7 +93,12 @@ public final class AppCloudSync { let store = makeStore() ICloudSyncPreferences.migrateLegacyTogglesIfNeeded(kvs: kvs, store: store) - let toggles = ICloudSyncPreferences.load(from: kvs, store: store) + var toggles = ICloudSyncPreferences.load(from: kvs, store: store) + // Merged UI toggle: settings sync implies dictionary sync. + if toggles.settings, !toggles.dictionary { + ICloudSyncPreferences.pushDictionaryEnabled(true, kvs: kvs) + toggles.dictionary = true + } ICloudSyncPreferences.cacheToAppGroup( settingsEnabled: toggles.settings, dictionaryEnabled: toggles.dictionary, diff --git a/OSGKeyboardShared/Services/ICloudSync/SettingsCloudSync.swift b/OSGKeyboardShared/Services/ICloudSync/SettingsCloudSync.swift index 5a5883c..9573bc4 100644 --- a/OSGKeyboardShared/Services/ICloudSync/SettingsCloudSync.swift +++ b/OSGKeyboardShared/Services/ICloudSync/SettingsCloudSync.swift @@ -62,9 +62,10 @@ public final class SettingsCloudSync { public func enableSync() async throws { let store = makeStore() ICloudSyncPreferences.pushSettingsEnabled(true, kvs: kvs) + ICloudSyncPreferences.pushDictionaryEnabled(true, kvs: kvs) ICloudSyncPreferences.cacheToAppGroup( settingsEnabled: true, - dictionaryEnabled: store.personalDictionaryICloudSyncEnabled, + dictionaryEnabled: true, store: store ) @@ -93,7 +94,9 @@ public final class SettingsCloudSync { public func disableSync() { let store = makeStore() ICloudSyncPreferences.pushSettingsEnabled(false, kvs: kvs) + ICloudSyncPreferences.pushDictionaryEnabled(false, kvs: kvs) store.setSettingsICloudSyncEnabled(false) + store.setPersonalDictionaryICloudSyncEnabled(false) } public func pullAndMerge(store: AppGroupStore) async { diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift index 812e520..8d4b3b4 100644 --- a/OSGKeyboardShared/Services/KeyboardState.swift +++ b/OSGKeyboardShared/Services/KeyboardState.swift @@ -107,8 +107,11 @@ public final class KeyboardState: ObservableObject { /// Defaults to `offLocaleId` so the keyboard boots in the "off" /// state on first install. @Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId - /// Mirrored from App Group — swaps delete / return on the bottom row. + /// Mirrored from App Group — swaps delete / space on the bottom row. @Published public var handednessPreference: HandednessPreference = .left + /// Mirrors the host field's return-key intent. The action stays a newline + /// insert; host apps decide whether that submits or creates a line break. + @Published public var returnKeyRole: ReturnKeyRole = .newline /// Press-and-drag pads beside the mic for four-way caret movement. @Published public var cursorDragNavigationEnabled: Bool = true /// `true` while a cursor-drag pad is being pressed — drives the hint @@ -151,6 +154,18 @@ public final class KeyboardState: ObservableObject { case openSettings } + public enum ReturnKeyRole: Equatable { + case newline + case send + + public var titleKey: String { + switch self { + case .newline: return "common.newline" + case .send: return "common.send" + } + } + } + // MARK: - Temporary Flow debug (remove after orange-mic investigation) /// Mirrored from `KeyboardFlowCoordinator` for the on-screen debug panel. diff --git a/OSGKeyboardShared/Services/LLMClient.swift b/OSGKeyboardShared/Services/LLMClient.swift index fc78309..59e980b 100644 --- a/OSGKeyboardShared/Services/LLMClient.swift +++ b/OSGKeyboardShared/Services/LLMClient.swift @@ -61,6 +61,8 @@ public struct OpenAICompatibleClient: LLMClient { public let baseURL: String public let apiKey: String public let model: String + public let providerId: String + public let thinkingEnabled: Bool public let session: URLSession /// Canonical request timeout for a single LLM HTTP round-trip. Both @@ -73,11 +75,15 @@ public struct OpenAICompatibleClient: LLMClient { baseURL: String, apiKey: String, model: String, + providerId: String = "", + thinkingEnabled: Bool = false, session: URLSession = .shared ) { self.baseURL = baseURL self.apiKey = apiKey self.model = model + self.providerId = providerId + self.thinkingEnabled = thinkingEnabled self.session = session } @@ -107,8 +113,13 @@ public struct OpenAICompatibleClient: LLMClient { // the baseline when the caller does not supply one. req.timeoutInterval = timeout ?? requestTimeout - let encoder = JSONEncoder() - req.httpBody = try encoder.encode(request) + req.httpBody = try Self.encodedBody( + request, + providerId: providerId, + baseURL: baseURL, + model: model, + thinkingEnabled: thinkingEnabled + ) do { let (data, response) = try await session.data(for: req) @@ -140,6 +151,27 @@ public struct OpenAICompatibleClient: LLMClient { throw LLMError.transport(String(describing: error)) } } + + private static func encodedBody( + _ request: LLMRequest, + providerId: String, + baseURL: String, + model: String, + thinkingEnabled: Bool + ) throws -> Data { + let encoded = try JSONEncoder().encode(request) + guard var body = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] else { + return encoded + } + LLMThinkingControl.apply( + to: &body, + providerId: providerId, + baseURL: baseURL, + model: model, + enabled: thinkingEnabled + ) + return try JSONSerialization.data(withJSONObject: body) + } } // MARK: - Factory @@ -147,13 +179,51 @@ public struct OpenAICompatibleClient: LLMClient { public enum LLMClientFactory { /// Build a client from the current `ProviderConfig`. public static func make(from config: ProviderConfig) -> LLMClient { - OpenAICompatibleClient( + make( + providerId: config.providerId, baseURL: config.baseURL, apiKey: config.apiKey, - model: config.model + model: config.model, + thinkingEnabled: config.llmThinkingEnabled ) } + /// Provider-aware factory used by `PolishingService`. + public static func make( + providerId: String, + baseURL: String, + apiKey: String, + model: String, + thinkingEnabled: Bool = false, + session: URLSession = .shared + ) -> LLMClient { + switch providerId { + case "anthropic": + return AnthropicMessagesClient(apiKey: apiKey, model: model, session: session) + default: + let resolvedBase = resolvedOpenAICompatibleBaseURL(providerId: providerId, baseURL: baseURL) + return OpenAICompatibleClient( + baseURL: resolvedBase, + apiKey: apiKey, + model: model, + providerId: providerId, + thinkingEnabled: thinkingEnabled, + session: session + ) + } + } + + /// Gemini exposes an OpenAI-compatible shim under `/v1beta/openai`. + private static func resolvedOpenAICompatibleBaseURL(providerId: String, baseURL: String) -> String { + if !baseURL.isEmpty { return baseURL } + switch providerId { + case "gemini": + return "https://generativelanguage.googleapis.com/v1beta/openai" + default: + return baseURL + } + } + /// Single source of truth for the LLM request timeout, shared by /// `LLMClient.requestTimeout` implementations and any caller that /// wants to bound total time spent waiting on the LLM (e.g. @@ -163,3 +233,100 @@ public enum LLMClientFactory { OpenAICompatibleClient(baseURL: "", apiKey: "", model: "").requestTimeout } } + +// MARK: - Provider-specific thinking controls +// +// Cloud polish defaults to thinking OFF (`llmThinkingEnabled == false`). +// DeepSeek V4 thinking defaults to *enabled* server-side, so we must send an +// explicit `thinking: { type: "disabled" }` — merely omitting the field (or +// sending `reasoning_effort: "low"`, which DeepSeek maps to `high`) leaves +// CoT on and makes polish appear stuck. + +enum LLMThinkingControl { + static func apply( + to body: inout [String: Any], + providerId: String, + baseURL: String, + model: String, + enabled: Bool + ) { + switch control(providerId: providerId, baseURL: baseURL, model: model) { + case .deepSeek: + // Official toggle; do not send reasoning_effort when disabled — + // DeepSeek maps low/medium → high while thinking stays on. + body["thinking"] = ["type": enabled ? "enabled" : "disabled"] + if enabled { + body["reasoning_effort"] = "high" + } else { + body.removeValue(forKey: "reasoning_effort") + } + case .miniMax: + body["thinking"] = ["type": enabled ? "adaptive" : "disabled"] + case .gemini: + body["thinking_config"] = [ + "thinking_budget": enabled ? -1 : 0 + ] + case .openAIReasoning: + // o-series / gpt-5: only touch the field when the user opts in, + // or when disabling an always-on reasoner with the lowest effort. + if enabled { + body["reasoning_effort"] = "medium" + } else { + body["reasoning_effort"] = "low" + } + case .none: + return + } + } + + private enum Control { + /// DeepSeek / Ark: explicit thinking type toggle. + case deepSeek + case miniMax + case gemini + case openAIReasoning + } + + private static func control( + providerId: String, + baseURL: String, + model: String + ) -> Control? { + switch providerId { + case "deepseek", "ark": + return .deepSeek + case "minimax": + return .miniMax + case "gemini": + return .gemini + case "openai": + return isOpenAIReasoningModel(model) ? .openAIReasoning : nil + default: + return control(baseURL: baseURL, model: model) + } + } + + private static func control(baseURL: String, model: String) -> Control? { + let lower = baseURL.lowercased() + if lower.contains("minimax") || lower.contains("minimaxi") { + return .miniMax + } + if lower.contains("generativelanguage.googleapis.com") { + return .gemini + } + // Hosted DeepSeek (SiliconFlow / OpenRouter / custom proxies). + if lower.contains("deepseek") || model.lowercased().contains("deepseek") { + return .deepSeek + } + return nil + } + + private static func isOpenAIReasoningModel(_ model: String) -> Bool { + let lower = model.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return lower.hasPrefix("o1") + || lower.hasPrefix("o3") + || lower.hasPrefix("o4") + || lower.hasPrefix("gpt-5") + || lower.contains("reasoning") + } +} diff --git a/OSGKeyboardShared/Services/PolishingService.swift b/OSGKeyboardShared/Services/PolishingService.swift index 53071fa..29f9cbc 100644 --- a/OSGKeyboardShared/Services/PolishingService.swift +++ b/OSGKeyboardShared/Services/PolishingService.swift @@ -170,7 +170,13 @@ public actor PolishingService { } else { apiKey = store.apiKey } - client = OpenAICompatibleClient(baseURL: baseURL, apiKey: apiKey, model: model) + client = LLMClientFactory.make( + providerId: effectiveProviderId, + baseURL: baseURL, + apiKey: apiKey, + model: model, + thinkingEnabled: store.llmThinkingEnabled + ) } let prompt: String @@ -350,7 +356,7 @@ public actor PolishingService { private func shouldUseChineseGuidance(providerId: String) -> Bool { switch providerId { - case "zhipu", "moonshot", "qwen", "deepseek": + case "zhipu", "moonshot", "qwen", "deepseek", "ark", "minimax", "siliconflow", "mimo": return true default: return false @@ -391,7 +397,7 @@ public actor PolishingService { PreconfiguredKeys.isDeepseekConfigured { return "deepseek" } - return id == "deepseek" && store.engineMode == "cloud" ? "openai" : id + return id } internal static func hasPolishAPIKey(store: any ConfigurationStore, providerId: String) -> Bool { diff --git a/OSGKeyboardShared/Services/ProviderModelService.swift b/OSGKeyboardShared/Services/ProviderModelService.swift new file mode 100644 index 0000000..3b1808f --- /dev/null +++ b/OSGKeyboardShared/Services/ProviderModelService.swift @@ -0,0 +1,182 @@ +// ProviderModelService.swift +// OSGKeyboard · Shared +// +// Lightweight provider tools used by Settings to validate endpoints and fetch +// model ids without coupling the UI to each vendor's response shape. + +import Foundation + +public enum ProviderModelServiceError: Error, LocalizedError, Sendable { + case invalidURL + case missingAPIKey + case http(Int) + case empty + case decoding + case transport(String) + + public var errorDescription: String? { + switch self { + case .invalidURL: + return SharedL10n.string("providerTools.error.invalidURL") + case .missingAPIKey: + return SharedL10n.string("providerTools.error.missingAPIKey") + case .http(let status): + return SharedL10n.format("providerTools.error.http", status) + case .empty: + return SharedL10n.string("providerTools.error.empty") + case .decoding: + return SharedL10n.string("providerTools.error.decoding") + case .transport: + return SharedL10n.string("providerTools.error.transport") + } + } +} + +public enum ProviderModelService { + public static func listLLMModels( + providerId: String, + baseURL: String, + apiKey: String, + currentModel: String, + session: URLSession = .shared + ) async throws -> [String] { + if providerId == "anthropic" { + return try await fetchModels( + baseURL: "https://api.anthropic.com/v1", + apiKey: apiKey, + authorization: .anthropic, + session: session + ) + } + return try await fetchModels( + baseURL: resolvedLLMBaseURL(providerId: providerId, baseURL: baseURL), + apiKey: apiKey, + authorization: .bearer, + session: session, + fallback: currentModel + ) + } + + public static func listASRModels( + providerId: String, + baseURL: String, + apiKey: String, + currentModel: String, + session: URLSession = .shared + ) async throws -> [String] { + switch CloudASRModelCatalog.strategy(for: providerId) { + case .volcengineStreaming, .bailianStreaming: + return singleModel(currentModel, fallback: CloudASRModelCatalog.defaultModel(for: providerId)) + case .localFallback: + return [] + case .prompt, .openRouterJson, .zhipuHotwords: + return try await fetchModels( + baseURL: baseURL.isEmpty ? LLMProvider.provider(id: providerId).defaultBaseURL : baseURL, + apiKey: apiKey, + authorization: .bearer, + session: session, + fallback: currentModel + ) + } + } + + private enum Authorization { + case bearer + case anthropic + } + + private static func fetchModels( + baseURL: String, + apiKey: String, + authorization: Authorization, + session: URLSession, + fallback: String = "" + ) async throws -> [String] { + guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw ProviderModelServiceError.missingAPIKey + } + guard let url = URL(string: modelsEndpoint(baseURL: baseURL)) else { + throw ProviderModelServiceError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 12 + request.setValue("application/json", forHTTPHeaderField: "Accept") + switch authorization { + case .bearer: + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + case .anthropic: + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + } + + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw ProviderModelServiceError.transport("non-HTTP response") + } + guard (200..<300).contains(http.statusCode) else { + throw ProviderModelServiceError.http(http.statusCode) + } + let models = try parseModels(from: data) + let resolved = models.isEmpty ? singleModel(fallback, fallback: "") : models + guard !resolved.isEmpty else { throw ProviderModelServiceError.empty } + return resolved + } catch let error as ProviderModelServiceError { + throw error + } catch { + throw ProviderModelServiceError.transport(String(describing: error)) + } + } + + private static func parseModels(from data: Data) throws -> [String] { + guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw ProviderModelServiceError.decoding + } + if let data = root["data"] as? [[String: Any]] { + return normalize(data.compactMap { $0["id"] as? String ?? $0["name"] as? String }) + } + if let models = root["models"] as? [[String: Any]] { + return normalize(models.compactMap { $0["id"] as? String ?? $0["name"] as? String }) + } + if let models = root["models"] as? [String] { + return normalize(models) + } + return [] + } + + private static func normalize(_ models: [String]) -> [String] { + var seen = Set() + return models + .map { model in + model + .replacingOccurrences(of: "models/", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + .filter { !$0.isEmpty } + .filter { seen.insert($0).inserted } + .sorted() + } + + private static func modelsEndpoint(baseURL: String) -> String { + let trimmed = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasSuffix("/models") { return trimmed } + return trimmed.hasSuffix("/") ? "\(trimmed)models" : "\(trimmed)/models" + } + + private static func resolvedLLMBaseURL(providerId: String, baseURL: String) -> String { + if !baseURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return baseURL } + if providerId == "gemini" { + return "https://generativelanguage.googleapis.com/v1beta/openai" + } + return LLMProvider.provider(id: providerId).defaultBaseURL + } + + private static func singleModel(_ model: String, fallback: String) -> [String] { + let resolved = model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? fallback + : model + return resolved.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? [] : [resolved] + } +} diff --git a/OSGKeyboardShared/Services/ProviderToolRunnerState.swift b/OSGKeyboardShared/Services/ProviderToolRunnerState.swift new file mode 100644 index 0000000..c696c49 --- /dev/null +++ b/OSGKeyboardShared/Services/ProviderToolRunnerState.swift @@ -0,0 +1,130 @@ +// ProviderToolRunnerState.swift +// OSGKeyboard · Shared +// +// Pure state machine for Settings provider tool rows (validate / fetch models). + +import Foundation + +public struct ProviderToolRunnerState: Equatable, Sendable { + public var isRunning: Bool + public var message: String? + public var failed: Bool + public var models: [String] + + public init( + isRunning: Bool = false, + message: String? = nil, + failed: Bool = false, + models: [String] = [] + ) { + self.isRunning = isRunning + self.message = message + self.failed = failed + self.models = models + } +} + +@MainActor +public enum ProviderToolRunner { + public static func runValidate( + runningMessage: String, + successMessage: String, + validate: () async throws -> Void + ) async -> ProviderToolRunnerState { + var state = ProviderToolRunnerState(isRunning: true, message: runningMessage, failed: false) + do { + try await validate() + state.isRunning = false + state.message = successMessage + state.failed = false + } catch { + state.isRunning = false + state.failed = true + state.message = (error as? LocalizedError)?.errorDescription ?? "\(error)" + } + return state + } + + public static func runFetchModels( + runningMessage: String, + loadedMessage: (Int) -> String, + emptyMessage: String, + currentModel: String, + fetchModels: () async throws -> [String] + ) async -> (state: ProviderToolRunnerState, selectedModel: String?) { + var state = ProviderToolRunnerState(isRunning: true, message: runningMessage, failed: false) + do { + let fetched = try await fetchModels() + guard !fetched.isEmpty else { + state.isRunning = false + state.failed = true + state.message = emptyMessage + state.models = [] + return (state, nil) + } + + var resolved = fetched + let trimmed = currentModel.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty, !resolved.contains(trimmed) { + resolved.insert(trimmed, at: 0) + } + state.models = resolved + state.isRunning = false + state.failed = false + state.message = loadedMessage(resolved.count) + + let selected: String? + if trimmed.isEmpty, let first = resolved.first { + selected = first + } else { + selected = nil + } + return (state, selected) + } catch { + state.isRunning = false + state.failed = true + state.message = (error as? LocalizedError)?.errorDescription ?? "\(error)" + state.models = [] + return (state, nil) + } + } +} + +public enum HardTimeout { + /// Returns the first completed result; the losing task is cancelled. + public static func run( + seconds: TimeInterval, + operation: @escaping @Sendable () async throws -> T + ) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await operation() } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw CancellationError() + } + guard let result = try await group.next() else { + throw CancellationError() + } + group.cancelAll() + return result + } + } + + /// Non-throwing variant for tasks that should fall back when time elapses. + public static func value( + seconds: TimeInterval, + operation: @escaping @Sendable () async -> T, + onTimeout: @escaping @Sendable () -> T + ) async -> T { + await withTaskGroup(of: T.self) { group in + group.addTask { await operation() } + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + return onTimeout() + } + let result = await group.next() ?? onTimeout() + group.cancelAll() + return result + } + } +} diff --git a/OSGKeyboardShared/Services/UsageStatisticsStore.swift b/OSGKeyboardShared/Services/UsageStatisticsStore.swift index 75ed0af..e178446 100644 --- a/OSGKeyboardShared/Services/UsageStatisticsStore.swift +++ b/OSGKeyboardShared/Services/UsageStatisticsStore.swift @@ -14,6 +14,13 @@ public final class UsageStatisticsStore: ObservableObject { @Published public private(set) var dictationDurationSeconds: TimeInterval = 0 @Published public private(set) var dictationCharacterCount: Int = 0 @Published public private(set) var translationCharacterCount: Int = 0 + /// Cross-device dictation characters per local day (`yyyy-MM-dd`), used by + /// the home page's 7-day chart. + @Published public private(set) var dailyDictationCharacters: [String: Int] = [:] + + /// How many days of daily buckets to retain on disk. Well beyond the 7-day + /// chart window so a device that syncs in late still contributes recent days. + private static let dailyRetentionDays = 90 public let defaults: UserDefaults @@ -53,6 +60,9 @@ public final class UsageStatisticsStore: ObservableObject { slice.translationCharacterCount += count } else { slice.dictationCharacterCount += count + let dayKey = UsageStatisticsDayKey.key(for: Date()) + slice.dailyDictationCharacters[dayKey, default: 0] += count + UsageStatisticsDayKey.prune(&slice.dailyDictationCharacters, keepingDays: Self.dailyRetentionDays) } slice.dictationDurationSeconds += max(0, duration) slice.updatedAt = Date() @@ -69,10 +79,42 @@ public final class UsageStatisticsStore: ObservableObject { /// aggregated cross-device sum and NEVER writes it back (writing would /// corrupt the per-device slices — see `recordUtterance`). public func reloadFromDisk() { - let aggregated = SyncedUsageStatisticsStorage.load(from: defaults).aggregated + let payload = SyncedUsageStatisticsStorage.load(from: defaults) + let aggregated = payload.aggregated dictationDurationSeconds = aggregated.dictationDurationSeconds dictationCharacterCount = aggregated.dictationCharacterCount translationCharacterCount = aggregated.translationCharacterCount + dailyDictationCharacters = payload.aggregatedDailyDictationCharacters + } + + // MARK: - 7-day chart data + + /// One day's dictation total for the home page chart. + public struct DailyUsagePoint: Identifiable, Equatable, Sendable { + public let date: Date + public let value: Int + public var id: Date { date } + } + + /// The trailing 7 local days (oldest → newest), zero-filled for days with no + /// dictation, so the chart always renders a full week. + public var last7Days: [DailyUsagePoint] { + Self.last7Days(from: dailyDictationCharacters) + } + + public static func last7Days( + from daily: [String: Int], + now: Date = Date(), + calendar: Calendar = .current + ) -> [DailyUsagePoint] { + let startOfToday = calendar.startOfDay(for: now) + var points: [DailyUsagePoint] = [] + for offset in stride(from: 6, through: 0, by: -1) { + guard let day = calendar.date(byAdding: .day, value: -offset, to: startOfToday) else { continue } + let key = UsageStatisticsDayKey.key(for: day, calendar: calendar) + points.append(DailyUsagePoint(date: day, value: daily[key] ?? 0)) + } + return points } /// One-time cleanup: the pre-fix code overwrote a device slice with the diff --git a/OSGKeyboardShared/en.lproj/Shared.strings b/OSGKeyboardShared/en.lproj/Shared.strings index 8ab9054..ed6a91d 100644 --- a/OSGKeyboardShared/en.lproj/Shared.strings +++ b/OSGKeyboardShared/en.lproj/Shared.strings @@ -11,11 +11,27 @@ /* LLM providers */ "provider.openai" = "OpenAI"; +"provider.ark" = "Volcengine Ark"; "provider.deepseek" = "DeepSeek"; "provider.qwen" = "Qwen (DashScope)"; "provider.zhipu" = "Zhipu GLM"; "provider.moonshot" = "Moonshot"; +"provider.siliconflow" = "SiliconFlow"; +"provider.groq" = "Groq"; +"provider.minimax" = "MiniMax"; "provider.mimo" = "Xiaomi MiMo"; +"provider.openrouter" = "OpenRouter"; +"provider.gemini" = "Google Gemini"; +"provider.anthropic" = "Anthropic Claude"; +"provider.xai" = "xAI Grok"; +"provider.mistral" = "Mistral AI"; +"provider.cometapi" = "CometAPI"; +"provider.alibabaCoding" = "Alibaba Coding"; +"provider.codingPlanX" = "CodingPlanX"; +"provider.volcengine" = "Volcengine ASR"; +"provider.bailian" = "Bailian Realtime ASR"; +"provider.whisper" = "Whisper (OpenAI)"; +"provider.codex_oauth" = "Codex OAuth"; "provider.custom" = "Custom"; /* LLM errors */ @@ -44,6 +60,15 @@ "error.cloudASR.emptyTranscript" = "Cloud ASR returned an empty transcript."; "error.cloudASR.audioTooLong" = "Audio segment is too long for this cloud ASR provider."; "error.cloudASR.providerUnsupported" = "This provider does not support cloud speech recognition yet."; +"error.cloudASR.streamingNotImplemented" = "This provider requires streaming ASR (WebSocket), which is not available in this build yet. Try Qwen, Zhipu, Groq, or OpenAI."; + +/* Provider tools */ +"providerTools.error.invalidURL" = "Invalid model endpoint."; +"providerTools.error.missingAPIKey" = "API Key is missing."; +"providerTools.error.http" = "Model endpoint returned HTTP %lld."; +"providerTools.error.empty" = "No models returned."; +"providerTools.error.decoding" = "Failed to parse model list."; +"providerTools.error.transport" = "Network error while loading models."; /* Polish scenarios */ "polishScenario.daily_chat" = "Daily Chat"; @@ -120,14 +145,17 @@ "mac.status.pasted" = "Inserted into front app"; "mac.status.copiedAndPasted" = "Copied and inserted"; "mac.status.deliveryWithNote" = "%@ — %@"; -"mac.stat.dictationTime" = "Dictation Time"; -"mac.stat.words" = "Dictation Chars"; -"mac.stat.translation" = "Translation Chars"; -"mac.stat.dictionary" = "Dictionary"; -"mac.stat.cumulativeDuration" = "Total time"; -"mac.stat.transcribed" = "Transcribed"; -"mac.stat.cumulativeTranslation" = "Translated"; -"mac.stat.customTerms" = "Custom terms"; +"stat.dictationTime" = "Dictation Time"; +"stat.words" = "Dictation Chars"; +"stat.translation" = "Translation Chars"; +"stat.dictionary" = "Dictionary"; +"stat.cumulativeDuration" = "Total time"; +"stat.transcribed" = "Transcribed"; +"stat.cumulativeTranslation" = "Translated"; +"stat.customTerms" = "Custom terms"; +"stat.weekChart.title" = "Last 7 days"; +"stat.weekChart.caption" = "Dictation chars"; +"stat.weekChart.empty" = "No dictation yet this week"; "mac.status.chipReady" = "Ready"; "mac.status.chipProcessing" = "Processing"; "mac.overlay.listening" = "Listening"; @@ -173,6 +201,25 @@ "mac.settings.service" = "Service"; "mac.settings.apiKey" = "API Key"; "mac.settings.model" = "Model"; +"mac.settings.modelFetchHint" = "Enter an API Key, then fetch models."; +"mac.settings.connectionCheck" = "Connection check"; +"mac.settings.validate" = "Validate"; +"mac.settings.fetchModels" = "Fetch models"; +"mac.settings.validating" = "Validating…"; +"mac.settings.validateSuccess" = "Success · Retry"; +"mac.settings.validateFailure" = "Failed · Retry"; +"mac.settings.loadingModels" = "Loading models…"; +"mac.settings.modelsLoaded" = "%lld models loaded."; +"mac.settings.selectModel" = "Select model"; +"mac.settings.modelSelected" = "Selected %@"; +"mac.settings.modelsEmptyHint" = "Tap refresh to load models"; +"mac.settings.thinking" = "Thinking"; +"mac.settings.thinkingSubtitle" = "Slower, higher quality — recommended off"; +"mac.settings.thinkingHint" = "Off by default. Enable only for slower, deeper reasoning."; +"mac.settings.volcengineAppId" = "APP ID"; +"mac.settings.volcengineAccessToken" = "Access Token"; +"mac.settings.volcengineResourceId" = "Resource ID"; +"mac.settings.volcengineNote" = "Secret Key is not required. Resource ID defaults to volc.seedasr.sauc.duration."; "mac.settings.recognition" = "RECOGNITION METHOD"; "mac.settings.cloudEngine" = "Cloud Engine & AI Refinement"; "mac.settings.cloudEngineDesc" = "Premium transcription via your provider's API, plus AI grammar and style polishing."; @@ -209,7 +256,7 @@ "mac.settings.mlxModelMissing" = "Select a Qwen3 MLX model folder below, or choose another installed model."; "mac.settings.selectedModelMissing" = "%@ is not installed — using Apple Speech for now."; "mac.settings.localModelFallbackApple" = "No local model is ready — using Apple Speech for now."; -"mac.settings.accessibility" = "Accessibility"; +"mac.settings.accessibility" = "Accessibility Permission"; "mac.settings.accessibilityDesc" = "Required for global shortcut and auto-paste."; "mac.settings.openAccessibility" = "Open System Settings"; "mac.settings.appearance" = "Appearance"; @@ -300,8 +347,8 @@ "mac.localASR.phase.completed" = "Completed"; "mac.error.accessibilityRequired" = "Enable Accessibility for OSGKeyboard in System Settings"; "mac.foregroundApp" = "Front app: %@"; -"mac.sync.settingsTitle" = "iCloud Sync"; -"mac.sync.settingsSubtitle" = "Sync settings, usage stats, speech history, and API keys across your devices via iCloud."; +"mac.sync.settingsTitle" = "Cross-Device iCloud Sync"; +"mac.sync.settingsSubtitle" = "Sync settings, history, and API keys across devices via iCloud."; "mac.sync.syncNow" = "Sync Now"; "mac.sync.dictTitle" = "Personal dictionary iCloud sync"; "mac.sync.dictSubtitle" = "Keep your dictionary in sync across all devices."; diff --git a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings index 9941628..1f7731b 100644 --- a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings +++ b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings @@ -11,11 +11,27 @@ /* LLM providers */ "provider.openai" = "OpenAI"; +"provider.ark" = "火山方舟 Ark"; "provider.deepseek" = "DeepSeek"; "provider.qwen" = "通义千问"; "provider.zhipu" = "智谱 GLM"; "provider.moonshot" = "月之暗面"; +"provider.siliconflow" = "硅基流动"; +"provider.groq" = "Groq"; +"provider.minimax" = "MiniMax"; "provider.mimo" = "小米 MiMo"; +"provider.openrouter" = "OpenRouter"; +"provider.gemini" = "Google Gemini"; +"provider.anthropic" = "Anthropic Claude"; +"provider.xai" = "xAI Grok"; +"provider.mistral" = "Mistral AI"; +"provider.cometapi" = "CometAPI"; +"provider.alibabaCoding" = "阿里 Coding"; +"provider.codingPlanX" = "CodingPlanX"; +"provider.volcengine" = "火山引擎 ASR"; +"provider.bailian" = "百炼实时 ASR"; +"provider.whisper" = "Whisper (OpenAI)"; +"provider.codex_oauth" = "Codex OAuth"; "provider.custom" = "自定义"; /* LLM errors */ @@ -44,6 +60,15 @@ "error.cloudASR.emptyTranscript" = "云端识别返回了空文本。"; "error.cloudASR.audioTooLong" = "音频片段超过该云端识别服务的时长限制。"; "error.cloudASR.providerUnsupported" = "该服务商暂不支持云端语音识别。"; +"error.cloudASR.streamingNotImplemented" = "该服务商需要流式 ASR(WebSocket),当前版本尚未接入。可改用通义、智谱、Groq 或 OpenAI。"; + +/* 服务商工具 */ +"providerTools.error.invalidURL" = "模型接口地址无效。"; +"providerTools.error.missingAPIKey" = "未填写 API Key。"; +"providerTools.error.http" = "模型接口返回 HTTP %lld。"; +"providerTools.error.empty" = "未返回可用模型。"; +"providerTools.error.decoding" = "解析模型列表失败。"; +"providerTools.error.transport" = "拉取模型时发生网络错误。"; /* 润色场景 */ "polishScenario.daily_chat" = "日常聊天"; @@ -120,14 +145,17 @@ "mac.status.pasted" = "已插入前台应用"; "mac.status.copiedAndPasted" = "已复制并插入"; "mac.status.deliveryWithNote" = "%@ — %@"; -"mac.stat.dictationTime" = "听写时长"; -"mac.stat.words" = "听写字数"; -"mac.stat.translation" = "翻译字数"; -"mac.stat.dictionary" = "词库"; -"mac.stat.cumulativeDuration" = "累计时长"; -"mac.stat.transcribed" = "累计转写"; -"mac.stat.cumulativeTranslation" = "累计翻译"; -"mac.stat.customTerms" = "自定义词条"; +"stat.dictationTime" = "听写时长"; +"stat.words" = "听写字数"; +"stat.translation" = "翻译字数"; +"stat.dictionary" = "词库"; +"stat.cumulativeDuration" = "累计时长"; +"stat.transcribed" = "累计转写"; +"stat.cumulativeTranslation" = "累计翻译"; +"stat.customTerms" = "自定义词条"; +"stat.weekChart.title" = "近 7 天"; +"stat.weekChart.caption" = "听写字数"; +"stat.weekChart.empty" = "本周还没有听写记录"; "mac.status.chipReady" = "就绪"; "mac.status.chipProcessing" = "处理中"; "mac.overlay.listening" = "聆听中"; @@ -173,6 +201,25 @@ "mac.settings.service" = "服务商"; "mac.settings.apiKey" = "API 密钥"; "mac.settings.model" = "模型"; +"mac.settings.modelFetchHint" = "填写 API Key 后拉取模型。"; +"mac.settings.connectionCheck" = "连接检查"; +"mac.settings.validate" = "验证"; +"mac.settings.fetchModels" = "拉取模型"; +"mac.settings.validating" = "正在验证…"; +"mac.settings.validateSuccess" = "成功 · 重试"; +"mac.settings.validateFailure" = "失败 · 重试"; +"mac.settings.loadingModels" = "正在拉取模型…"; +"mac.settings.modelsLoaded" = "已拉取 %lld 个模型。"; +"mac.settings.selectModel" = "选择模型"; +"mac.settings.modelSelected" = "已选择 %@"; +"mac.settings.modelsEmptyHint" = "先点右侧刷新拉取模型"; +"mac.settings.thinking" = "思考"; +"mac.settings.thinkingSubtitle" = "速度更慢、质量更高,建议关闭"; +"mac.settings.thinkingHint" = "默认关闭。仅在需要更慢、更深的推理时开启。"; +"mac.settings.volcengineAppId" = "APP ID"; +"mac.settings.volcengineAccessToken" = "Access Token"; +"mac.settings.volcengineResourceId" = "Resource ID"; +"mac.settings.volcengineNote" = "Secret Key 当前无需填写。Resource ID 默认使用 volc.seedasr.sauc.duration。"; "mac.settings.recognition" = "识别方式"; "mac.settings.cloudEngine" = "云端引擎与 AI 润色"; "mac.settings.cloudEngineDesc" = "通过服务商 API 进行高质量转写,并自动润色语法与风格。"; @@ -209,7 +256,7 @@ "mac.settings.mlxModelMissing" = "请在下方选择 Qwen3 MLX 模型目录,或改用其他已安装的模型。"; "mac.settings.selectedModelMissing" = "「%@」尚未安装,暂时使用 Apple Speech。"; "mac.settings.localModelFallbackApple" = "没有可用的本地模型,暂时使用 Apple Speech。"; -"mac.settings.accessibility" = "辅助功能"; +"mac.settings.accessibility" = "辅助功能权限"; "mac.settings.accessibilityDesc" = "全局快捷键与自动粘贴需要此权限。"; "mac.settings.openAccessibility" = "打开系统设置"; "mac.settings.appearance" = "外观"; @@ -300,8 +347,8 @@ "mac.localASR.phase.completed" = "已完成"; "mac.error.accessibilityRequired" = "请在系统设置中为 OSGKeyboard 启用辅助功能"; "mac.foregroundApp" = "前台应用:%@"; -"mac.sync.settingsTitle" = "iCloud 同步"; -"mac.sync.settingsSubtitle" = "通过 iCloud 在多台设备间同步设置、使用统计、语音历史与 API 密钥。"; +"mac.sync.settingsTitle" = "跨设备iCloud 同步"; +"mac.sync.settingsSubtitle" = "通过 iCloud 跨设备同步设置、历史记录、API Key"; "mac.sync.syncNow" = "立即同步"; "mac.sync.dictTitle" = "个人词库 iCloud 同步"; "mac.sync.dictSubtitle" = "在所有设备间同步个人词库。"; diff --git a/OSGKeyboardTests/AppGroupConfigurationTests.swift b/OSGKeyboardTests/AppGroupConfigurationTests.swift index 489a9c2..aa75a85 100644 --- a/OSGKeyboardTests/AppGroupConfigurationTests.swift +++ b/OSGKeyboardTests/AppGroupConfigurationTests.swift @@ -17,7 +17,8 @@ final class AppGroupConfigurationTests: XCTestCase { let defaults = makeDefaults() let config = AppGroupConfiguration.load(fromAvailable: defaults) - XCTAssertEqual(config.providerId, "openai") + XCTAssertEqual(config.providerId, "deepseek") + XCTAssertEqual(config.asrProviderId, "volcengine") XCTAssertEqual(config.modeId, "polish") XCTAssertEqual(config.localeId, "auto") // Privacy-critical: the default engine must keep audio on-device. @@ -125,14 +126,14 @@ final class AppGroupConfigurationTests: XCTestCase { XCTAssertFalse(config.translationEnabled) } - func testCloudDeepSeekProviderMigratesToOpenAI() { + func testCloudDeepSeekProviderIsPreserved() { let defaults = makeDefaults() defaults.set("deepseek", forKey: AppGroupConfiguration.Keys.providerId) defaults.set("cloud", forKey: AppGroupConfiguration.Keys.engineMode) let config = AppGroupConfiguration.load(fromAvailable: defaults) - XCTAssertEqual(config.providerId, "openai") - XCTAssertEqual(defaults.string(forKey: AppGroupConfiguration.Keys.providerId), "openai") + XCTAssertEqual(config.providerId, "deepseek") + XCTAssertEqual(defaults.string(forKey: AppGroupConfiguration.Keys.providerId), "deepseek") } func testPolishIntensityLegacyOffMigratesToMedium() { diff --git a/OSGKeyboardTests/CloudASRTests.swift b/OSGKeyboardTests/CloudASRTests.swift index 22caed7..3e5f1a2 100644 --- a/OSGKeyboardTests/CloudASRTests.swift +++ b/OSGKeyboardTests/CloudASRTests.swift @@ -8,26 +8,122 @@ final class CloudASRTests: XCTestCase { func testCloudASRStrategyRouting() { XCTAssertEqual(CloudASRModelCatalog.strategy(for: "zhipu"), .zhipuHotwords) - XCTAssertEqual(CloudASRModelCatalog.strategy(for: "qwen"), .alibabaVocabulary) + XCTAssertEqual(CloudASRModelCatalog.strategy(for: "qwen"), .localFallback) + XCTAssertEqual(CloudASRModelCatalog.strategy(for: "bailian"), .bailianStreaming) XCTAssertEqual(CloudASRModelCatalog.strategy(for: "openai"), .prompt) + XCTAssertEqual(CloudASRModelCatalog.strategy(for: "whisper"), .prompt) XCTAssertEqual(CloudASRModelCatalog.strategy(for: "mimo"), .prompt) + XCTAssertEqual(CloudASRModelCatalog.strategy(for: "groq"), .prompt) + XCTAssertEqual(CloudASRModelCatalog.strategy(for: "siliconflow"), .prompt) + XCTAssertEqual(CloudASRModelCatalog.strategy(for: "openrouter"), .openRouterJson) + XCTAssertEqual(CloudASRModelCatalog.strategy(for: "volcengine"), .volcengineStreaming) XCTAssertEqual(CloudASRModelCatalog.strategy(for: "moonshot"), .localFallback) + XCTAssertEqual(CloudASRModelCatalog.strategy(for: "ark"), .localFallback) } func testCloudASRModelDefaults() { - XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "qwen"), "fun-asr-flash-2026-06-15") + XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "bailian"), "fun-asr-realtime") XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "zhipu"), "glm-asr-2512") XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "mimo"), "mimo-v2.5-asr") XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "openai"), "gpt-4o-mini-transcribe") + XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "whisper"), "whisper-1") + XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "groq"), "whisper-large-v3-turbo") + XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "siliconflow"), "FunAudioLLM/SenseVoiceSmall") + XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "openrouter"), "openai/whisper-large-v3-turbo") + XCTAssertEqual(CloudASRModelCatalog.defaultModel(for: "volcengine"), "volc.seedasr.sauc.duration") + } + + func testAsrSelectablePresetsAllowlist() { + let ids = Set(LLMProvider.asrSelectablePresets.map(\.id)) + XCTAssertTrue(ids.contains("groq")) + XCTAssertTrue(ids.contains("siliconflow")) + XCTAssertTrue(ids.contains("openrouter")) + XCTAssertTrue(ids.contains("bailian")) + XCTAssertTrue(ids.contains("whisper")) + XCTAssertTrue(ids.contains("volcengine")) + XCTAssertFalse(ids.contains("qwen")) + XCTAssertFalse(ids.contains("moonshot")) + XCTAssertFalse(ids.contains("ark")) + XCTAssertFalse(ids.contains("anthropic")) + XCTAssertFalse(ids.contains("gemini")) + } + + func testPolishOnlyProvidersExcludedFromASRPicker() { + let polishIds = Set(LLMProvider.userSelectablePresets.map(\.id)) + let asrIds = Set(LLMProvider.asrSelectablePresets.map(\.id)) + XCTAssertTrue(polishIds.contains("ark")) + XCTAssertFalse(asrIds.contains("ark")) + XCTAssertTrue(polishIds.contains("gemini")) + XCTAssertFalse(asrIds.contains("gemini")) } func testPersonalDictionaryCloudASRBadgeProviders() { XCTAssertTrue(LLMProvider.provider(id: "zhipu").supportsPersonalDictionaryCloudASR) - XCTAssertTrue(LLMProvider.provider(id: "qwen").supportsPersonalDictionaryCloudASR) + XCTAssertFalse(LLMProvider.provider(id: "qwen").supportsPersonalDictionaryCloudASR) + XCTAssertFalse(LLMProvider.provider(id: "bailian").supportsPersonalDictionaryCloudASR) XCTAssertFalse(LLMProvider.provider(id: "openai").supportsPersonalDictionaryCloudASR) XCTAssertFalse(LLMProvider.provider(id: "moonshot").supportsPersonalDictionaryCloudASR) } + func testShowsASREndpointField() { + XCTAssertTrue(CloudASRModelCatalog.showsASREndpointField(for: "bailian")) + XCTAssertTrue(CloudASRModelCatalog.showsASREndpointField(for: "openai")) + XCTAssertFalse(CloudASRModelCatalog.showsASREndpointField(for: "qwen")) + XCTAssertFalse(CloudASRModelCatalog.showsASREndpointField(for: "volcengine")) + } + + func testBailianMergeSegmentsDedupesOverlap() { + let merged = BailianRealtimeASRClient.mergeSegments(["你好吗", "好吗我们"]) + XCTAssertEqual(merged, "你好吗我们") + } + + func testBailianRunTaskMessageIncludesModel() { + let json = BailianRealtimeASRClient.runTaskMessage( + taskID: "task-1", + model: "fun-asr-realtime", + vocabularyID: nil + ) + XCTAssertTrue(json.contains("fun-asr-realtime")) + XCTAssertTrue(json.contains("run-task")) + XCTAssertTrue(json.contains("\"format\":\"pcm\"") || json.contains("\"format\": \"pcm\"")) + XCTAssertTrue(json.contains("16000") || json.contains("16_000")) + } + + func testLegacyQwenASRConfigMigratesToBailian() { + let suite = "group.com.osgkeyboard.tests.qwen-asr.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + defer { defaults.removePersistentDomain(forName: suite) } + + defaults.set("qwen", forKey: AppGroupConfiguration.Keys.asrProviderId) + defaults.set("https://dashscope.aliyuncs.com/compatible-mode/v1", forKey: AppGroupConfiguration.Keys.asrBaseURL) + defaults.set("fun-asr-flash-2026-06-15", forKey: AppGroupConfiguration.Keys.asrModel) + + let config = AppGroupConfiguration.load(fromAvailable: defaults) + XCTAssertEqual(config.asrProviderId, "bailian") + XCTAssertEqual(config.asrBaseURL, CloudASRModelCatalog.bailianDefaultEndpoint) + XCTAssertEqual(config.asrModel, CloudASRModelCatalog.alibabaFunASRRealtime) + } + + func testVolcengineASRFieldsJSONParsing() { + let json = #"{"app_id":"app-1","access_token":"tok-2","resource_id":"res-3"}"# + let fields = VolcengineASRFields.parse(apiKey: json, resourceFallback: "") + XCTAssertEqual(fields.appID, "app-1") + XCTAssertEqual(fields.accessToken, "tok-2") + XCTAssertEqual(fields.resourceID, "res-3") + } + + func testVolcengineASRFieldsColonParsing() { + let fields = VolcengineASRFields.parse( + apiKey: "app-1:tok-2:res-3", + resourceFallback: CloudASRModelCatalog.defaultModel(for: "volcengine") + ) + XCTAssertEqual(fields.appID, "app-1") + XCTAssertEqual(fields.accessToken, "tok-2") + XCTAssertEqual(fields.resourceID, "res-3") + XCTAssertTrue(fields.encodedAPIKey.contains("app-1")) + } + func testPersonalDictionaryASRHotwordsDedupesTerms() { let dict = PersonalDictionary(entries: [ PersonalDictionary.Entry(term: "Kubernetes", category: .technical, source: .manual), diff --git a/OSGKeyboardTests/ConfigurationStoreTests.swift b/OSGKeyboardTests/ConfigurationStoreTests.swift index fcf0276..500f6a1 100644 --- a/OSGKeyboardTests/ConfigurationStoreTests.swift +++ b/OSGKeyboardTests/ConfigurationStoreTests.swift @@ -62,9 +62,9 @@ final class ConfigurationStoreTests: XCTestCase { XCTAssertTrue(asrClient is ZhipuCloudASRClient) } - func testLegacyInstallCopiesProviderIdToAsrProviderId() { + func testLegacyInstallCopiesProviderIdToAsrProviderIdThenMigratesQwen() { defaults.set("qwen", forKey: AppGroupConfiguration.Keys.providerId) let config = AppGroupConfiguration.load(fromAvailable: defaults) - XCTAssertEqual(config.asrProviderId, "qwen") + XCTAssertEqual(config.asrProviderId, "bailian") } } diff --git a/OSGKeyboardTests/FlowHandoffPolicyTests.swift b/OSGKeyboardTests/FlowHandoffPolicyTests.swift new file mode 100644 index 0000000..8f46371 --- /dev/null +++ b/OSGKeyboardTests/FlowHandoffPolicyTests.swift @@ -0,0 +1,322 @@ +// FlowHandoffPolicyTests.swift +// OSGKeyboardTests + +import XCTest +@testable import OSGKeyboardShared + +final class FlowHandoffPolicyTests: XCTestCase { + + // MARK: - shouldTreatHostAsAlive / shouldOpenHostColdStart + + func testAliveWhenSessionActiveAndReachable() { + XCTAssertTrue( + FlowHandoffPolicy.shouldTreatHostAsAlive( + sessionActive: true, + hostReachable: true, + hostStale: false, + withinReadyGrace: false + ) + ) + XCTAssertFalse( + FlowHandoffPolicy.shouldOpenHostColdStart( + sessionActive: true, + hostReachable: true, + hostStale: false, + withinReadyGrace: false + ) + ) + } + + func testAliveWhenSessionActiveWithinReadyGrace() { + // Finalize race: ready flap but we were ready moments ago. + XCTAssertTrue( + FlowHandoffPolicy.shouldTreatHostAsAlive( + sessionActive: true, + hostReachable: false, + hostStale: false, + withinReadyGrace: true + ) + ) + XCTAssertFalse( + FlowHandoffPolicy.shouldOpenHostColdStart( + sessionActive: true, + hostReachable: false, + hostStale: false, + withinReadyGrace: true + ) + ) + } + + func testAliveWhenSessionActiveEvenIfHeartbeatBrieflyStale() { + // Session flag still valid and not zombie → wait, do not jump. + XCTAssertTrue( + FlowHandoffPolicy.shouldTreatHostAsAlive( + sessionActive: true, + hostReachable: false, + hostStale: false, + withinReadyGrace: false + ) + ) + XCTAssertFalse( + FlowHandoffPolicy.shouldOpenHostColdStart( + sessionActive: true, + hostReachable: false, + hostStale: false, + withinReadyGrace: false + ) + ) + } + + func testDeadWhenHostStale() { + XCTAssertFalse( + FlowHandoffPolicy.shouldTreatHostAsAlive( + sessionActive: true, + hostReachable: false, + hostStale: true, + withinReadyGrace: true + ) + ) + XCTAssertTrue( + FlowHandoffPolicy.shouldOpenHostColdStart( + sessionActive: true, + hostReachable: false, + hostStale: true, + withinReadyGrace: true + ) + ) + } + + func testDeadWhenNoSession() { + XCTAssertFalse( + FlowHandoffPolicy.shouldTreatHostAsAlive( + sessionActive: false, + hostReachable: false, + hostStale: false, + withinReadyGrace: false + ) + ) + XCTAssertTrue( + FlowHandoffPolicy.shouldOpenHostColdStart( + sessionActive: false, + hostReachable: false, + hostStale: false, + withinReadyGrace: false + ) + ) + } + + // MARK: - micPressAction + + func testMicPressReadyStartsRecording() { + let action = FlowHandoffPolicy.micPressAction( + availability: .ready, + sessionActive: true, + hostReachable: true, + hostStale: false, + withinReadyGrace: false + ) + XCTAssertEqual(action, .startRecording) + } + + func testMicPressPreparingSessionWaitsAndRecords() { + // 0.5.2 regression: preparingSession must NOT open cold start. + let action = FlowHandoffPolicy.micPressAction( + availability: .unavailable(.preparingSession), + sessionActive: true, + hostReachable: true, + hostStale: false, + withinReadyGrace: false + ) + XCTAssertEqual(action, .waitForHostReady(recordWhenReady: true)) + } + + func testMicPressHostNotReadyWithLiveSessionWaits() { + // User log scenario: finalize just completed, stale ready=false frame. + let action = FlowHandoffPolicy.micPressAction( + availability: .unavailable(.hostNotReady), + sessionActive: true, + hostReachable: true, + hostStale: false, + withinReadyGrace: true + ) + XCTAssertEqual(action, .waitForHostReady(recordWhenReady: true)) + } + + func testMicPressHostNotReadyWithActiveSessionNoGraceStillWaits() { + let action = FlowHandoffPolicy.micPressAction( + availability: .unavailable(.hostNotReady), + sessionActive: true, + hostReachable: false, + hostStale: false, + withinReadyGrace: false + ) + XCTAssertEqual(action, .waitForHostReady(recordWhenReady: true)) + } + + func testMicPressHostNotReadyWhenDeadOpensColdStart() { + let action = FlowHandoffPolicy.micPressAction( + availability: .unavailable(.hostNotReady), + sessionActive: false, + hostReachable: false, + hostStale: false, + withinReadyGrace: false + ) + XCTAssertEqual(action, .openHostColdStart) + } + + func testMicPressHostNotReadyWhenStaleOpensColdStart() { + let action = FlowHandoffPolicy.micPressAction( + availability: .unavailable(.hostNotReady), + sessionActive: true, + hostReachable: false, + hostStale: true, + withinReadyGrace: false + ) + XCTAssertEqual(action, .openHostColdStart) + } + + func testMicPressBusyPhasesIgnored() { + XCTAssertEqual( + FlowHandoffPolicy.micPressAction( + availability: .recording, + sessionActive: true, + hostReachable: true, + hostStale: false, + withinReadyGrace: false + ), + .ignore + ) + XCTAssertEqual( + FlowHandoffPolicy.micPressAction( + availability: .processing, + sessionActive: true, + hostReachable: true, + hostStale: false, + withinReadyGrace: false + ), + .ignore + ) + } + + // MARK: - coldStartOverlayDecision + + func testOverlaySilencedWhenAlreadyReady() { + // User log: active=true hostReady=true coldStart=true → must silence. + XCTAssertEqual( + FlowHandoffPolicy.coldStartOverlayDecision( + sessionIsActive: true, + hostIsReady: true, + isUtteranceBusy: false + ), + .silence + ) + } + + func testOverlaySilencedWhenUtteranceBusy() { + XCTAssertEqual( + FlowHandoffPolicy.coldStartOverlayDecision( + sessionIsActive: true, + hostIsReady: false, + isUtteranceBusy: true + ), + .silence + ) + } + + func testOverlayPresentedForTrueColdStart() { + XCTAssertEqual( + FlowHandoffPolicy.coldStartOverlayDecision( + sessionIsActive: false, + hostIsReady: false, + isUtteranceBusy: false + ), + .present + ) + } + + func testOverlayPresentedWhenActiveButNeedsRecovery() { + // Engine hiccup: session up, not ready, not busy → preparing UI OK. + XCTAssertEqual( + FlowHandoffPolicy.coldStartOverlayDecision( + sessionIsActive: true, + hostIsReady: false, + isUtteranceBusy: false + ), + .present + ) + } + + // MARK: - debouncer + proactive launch flag + + func testProactiveAutoLaunchDisabled() { + XCTAssertFalse(FlowHandoffPolicy.allowsProactiveHostAutoLaunch) + } + + func testDebouncerIgnoresSingleDeadSample() { + var debouncer = FlowColdStartDebouncer() + XCTAssertFalse(debouncer.observe(hostTrulyDead: true)) + XCTAssertEqual(debouncer.consecutiveDeadSamples, 1) + XCTAssertTrue(debouncer.observe(hostTrulyDead: true)) + XCTAssertEqual(debouncer.consecutiveDeadSamples, 2) + } + + func testDebouncerResetsOnAliveSample() { + var debouncer = FlowColdStartDebouncer() + XCTAssertFalse(debouncer.observe(hostTrulyDead: true)) + XCTAssertFalse(debouncer.observe(hostTrulyDead: false)) + XCTAssertEqual(debouncer.consecutiveDeadSamples, 0) + XCTAssertFalse(debouncer.observe(hostTrulyDead: true)) + } + + func testDebouncerReset() { + var debouncer = FlowColdStartDebouncer() + _ = debouncer.observe(hostTrulyDead: true) + debouncer.reset() + XCTAssertEqual(debouncer.consecutiveDeadSamples, 0) + } + + /// Mirrors the user log: session still active after finalize, ready flap + /// must not justify startflow even when availability reads hostNotReady. + func testFinalizeRaceDoesNotOpenColdStart() { + let suite = "group.com.osgkeyboard.shared.tests.handoff.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + + let sessionId = UUID() + FlowSessionBridge.markSessionActive(duration: 1_800, sessionId: sessionId, defaults: defaults) + FlowSessionBridge.writeReadySnapshot( + FlowReadySnapshot( + sessionId: sessionId, + ready: false, + reason: .processing, + engineMode: "local", + localeId: "zh-Hans", + busyUtteranceId: UUID(), + sessionExpiresAt: FlowSessionBridge.sessionExpiresAt(defaults: defaults), + hostGeneration: FlowSessionBridge.currentHostGeneration(defaults: defaults) + ), + defaults: defaults + ) + + XCTAssertTrue(FlowSessionBridge.isSessionActive(defaults: defaults)) + XCTAssertFalse(FlowSessionBridge.isHostReady(defaults: defaults)) + + let action = FlowHandoffPolicy.micPressAction( + availability: .unavailable(.hostNotReady), + sessionActive: FlowSessionBridge.isSessionActive(defaults: defaults), + hostReachable: FlowSessionBridge.isHostReachable(defaults: defaults), + hostStale: FlowSessionBridge.isHostStale(defaults: defaults), + withinReadyGrace: true + ) + XCTAssertEqual(action, .waitForHostReady(recordWhenReady: true)) + + XCTAssertEqual( + FlowHandoffPolicy.coldStartOverlayDecision( + sessionIsActive: true, + hostIsReady: true, + isUtteranceBusy: false + ), + .silence + ) + } +} diff --git a/OSGKeyboardTests/LLMClientTests.swift b/OSGKeyboardTests/LLMClientTests.swift index a1be712..f476da6 100644 --- a/OSGKeyboardTests/LLMClientTests.swift +++ b/OSGKeyboardTests/LLMClientTests.swift @@ -347,6 +347,83 @@ final class LLMClientTests: XCTestCase { /// Local engine pins DeepSeek — cloud-provider URL/model in App Group /// must not leak into the LLM request (regression: Qwen URL + DeepSeek key → 401). + func testLLMClientFactoryRoutesAnthropic() { + let client = LLMClientFactory.make( + providerId: "anthropic", + baseURL: "", + apiKey: "sk-test", + model: "claude-sonnet-4-6" + ) + XCTAssertTrue(client is AnthropicMessagesClient) + } + + func testLLMClientFactoryResolvesGeminiOpenAICompatBaseURL() { + let client = LLMClientFactory.make( + providerId: "gemini", + baseURL: "", + apiKey: "key", + model: "gemini-2.5-flash" + ) as! OpenAICompatibleClient + XCTAssertEqual( + client.baseURL, + "https://generativelanguage.googleapis.com/v1beta/openai" + ) + } + + /// DeepSeek V4 thinking defaults ON server-side; polish must explicitly disable it. + func testDeepSeekPolishDisablesThinkingByDefault() { + var body: [String: Any] = ["model": "deepseek-v4-flash"] + LLMThinkingControl.apply( + to: &body, + providerId: "deepseek", + baseURL: "https://api.deepseek.com/v1", + model: "deepseek-v4-flash", + enabled: false + ) + let thinking = body["thinking"] as? [String: Any] + XCTAssertEqual(thinking?["type"] as? String, "disabled") + XCTAssertNil(body["reasoning_effort"], "disabled path must not send reasoning_effort (low→high on DeepSeek)") + } + + func testDeepSeekPolishEnablesThinkingWhenToggledOn() { + var body: [String: Any] = ["model": "deepseek-v4-flash"] + LLMThinkingControl.apply( + to: &body, + providerId: "deepseek", + baseURL: "https://api.deepseek.com/v1", + model: "deepseek-v4-flash", + enabled: true + ) + let thinking = body["thinking"] as? [String: Any] + XCTAssertEqual(thinking?["type"] as? String, "enabled") + XCTAssertEqual(body["reasoning_effort"] as? String, "high") + } + + /// Ordinary OpenAI chat models must not get thinking fields when the toggle is off. + func testOpenAIChatDoesNotInjectThinkingWhenDisabled() { + var body: [String: Any] = ["model": "gpt-4o-mini"] + LLMThinkingControl.apply( + to: &body, + providerId: "openai", + baseURL: "https://api.openai.com/v1", + model: "gpt-4o-mini", + enabled: false + ) + XCTAssertNil(body["thinking"]) + XCTAssertNil(body["reasoning_effort"]) + XCTAssertNil(body["thinking_config"]) + } + + func testLLMThinkingDefaultIsOffInFreshConfiguration() { + let suiteName = "group.com.osgkeyboard.shared.tests.thinking.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let config = ProviderConfig(defaults: defaults) + XCTAssertFalse(config.llmThinkingEnabled, "cloud polish thinking must default off") + } + func testResolveLLMEndpointUsesPresetWhenProviderPinned() { let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! diff --git a/OSGKeyboardTests/SettingsCloudSyncTests.swift b/OSGKeyboardTests/SettingsCloudSyncTests.swift index a873890..50f12ef 100644 --- a/OSGKeyboardTests/SettingsCloudSyncTests.swift +++ b/OSGKeyboardTests/SettingsCloudSyncTests.swift @@ -60,6 +60,7 @@ final class SettingsCloudSyncTests: XCTestCase { handednessPreference: SyncedField(value: .left, updatedAt: stampA, deviceID: deviceA), cursorDragNavigationEnabled: SyncedField(value: true, updatedAt: stampA, deviceID: deviceA), polishIntensity: SyncedField(value: .medium, updatedAt: stampA, deviceID: deviceA), + llmThinkingEnabled: SyncedField(value: false, updatedAt: stampA, deviceID: deviceA), flowSkipAppSwitch: SyncedField(value: true, updatedAt: stampA, deviceID: deviceA), flowInactivityDuration: SyncedField(value: .twelveHours, updatedAt: stampA, deviceID: deviceA) ) @@ -79,6 +80,7 @@ final class SettingsCloudSyncTests: XCTestCase { handednessPreference: SyncedField(value: .right, updatedAt: stampB, deviceID: deviceB), cursorDragNavigationEnabled: SyncedField(value: false, updatedAt: stampB, deviceID: deviceB), polishIntensity: SyncedField(value: .light, updatedAt: stampB, deviceID: deviceB), + llmThinkingEnabled: SyncedField(value: true, updatedAt: stampB, deviceID: deviceB), flowSkipAppSwitch: SyncedField(value: false, updatedAt: stampB, deviceID: deviceB), flowInactivityDuration: SyncedField(value: .threeHours, updatedAt: stampB, deviceID: deviceB) ) @@ -129,6 +131,7 @@ final class SettingsCloudSyncTests: XCTestCase { try await settingsSync.enableSync() XCTAssertTrue(store.settingsICloudSyncEnabled) + XCTAssertTrue(store.personalDictionaryICloudSyncEnabled) XCTAssertEqual(Keychain.apiKey(for: "openai", preferICloudSync: true), "sk-local-openai") } diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..1908ddc --- /dev/null +++ b/README.en.md @@ -0,0 +1,103 @@ +# OSGKeyboard + +**Speak it. It's typed.** + +Voice input for iPhone, iPad, and Mac. Speak in any app — polished text lands at your cursor. + +![Platform](https://img.shields.io/badge/iOS%20%2F%20iPadOS-26%2B-0078D4?logo=apple) +![Platform](https://img.shields.io/badge/macOS-14%2B-555?logo=apple) +![Swift](https://img.shields.io/badge/Swift-6.0-FA7343?logo=swift) +![Version](https://img.shields.io/badge/version-0.5.3-3aa05a) +![License](https://img.shields.io/badge/license-Source%20Available-blue) + +[Website](https://hkgood.github.io/OSGKeyboard/) · [中文 README](./README.md) · [Privacy Policy](https://hkgood.github.io/OSGKeyboard/privacy/) + +--- + +## Why OSGKeyboard + +- **Works everywhere** — Messages, Notes, Notion, Cursor, Mail, WeChat — wherever you type +- **Speak, don't edit** — tap (iOS) or hold Option (Mac); AI adds punctuation and structure for you +- **On-device by default** — local recognition on iOS; optional local models on Mac. Cloud upload only when you opt in +- **Bring your own LLM** — built-in polish out of the box, or plug in DeepSeek, OpenAI, Anthropic, OpenRouter, and more +- **Mac global dictation** — menu-bar app, bottom overlay with live feedback, inserts into the frontmost app + +--- + +## Three steps + +1. **Install & authorize** — add the iOS keyboard with Full Access; grant mic + Accessibility on Mac +2. **Pick an engine** — local ASR + built-in polish (zero config), or your own API keys +3. **Start talking** — switch to OSGKeyboard, or hold Option on Mac + +--- + +## Platforms + +| | iOS / iPadOS | macOS | +|---|:---:|:---:| +| Keyboard / global hotkey | ✅ | ✅ hold Option | +| Local speech recognition | ✅ SpeechAnalyzer | ✅ SenseVoice / Qwen3 | +| AI polish | ✅ | ✅ | +| Post-polish translation | ✅ | ✅ | +| Personal dictionary | ✅ iCloud sync | ✅ | +| Dictation history | ✅ | ✅ | +| Live UI | ✅ Dynamic Island | ✅ floating pill | + +--- + +## Privacy + +Speech is transcribed on-device by default. Polish sends **text only** — not raw audio. We never log ordinary keystrokes. See the [Privacy Policy](https://hkgood.github.io/OSGKeyboard/privacy/). + +--- + +## Build from source + +Requires macOS with **Xcode 26** and [XcodeGen](https://github.com/yonaskolb/XcodeGen). + +```bash +git clone https://github.com/hkgood/OSGKeyboard.git +cd OSGKeyboard +./Scripts/generate-xcodeproj.sh +open OSGKeyboard.xcodeproj +``` + +Run tests: + +```bash +xcodebuild test -project OSGKeyboard.xcodeproj -scheme OSGKeyboard \ + -destination 'platform=iOS Simulator,name=iPhone 17' +``` + +--- + +## Architecture (brief) + +``` +OSGKeyboard/ Main iOS app (Flow session host) +OSGKeyboardExt/ Custom keyboard extension +OSGKeyboardMac/ macOS menu-bar app +OSGKeyboardShared/ Shared framework (ASR, LLM, sync, design system) +``` + +**Flow session model (iOS):** the host app keeps a long-lived audio session; the keyboard sends start/stop signals via App Group; polished text is delivered back for insertion. + +**Engine modes:** + +- `local` — on-device ASR; built-in polish (or your own LLM key) +- `cloud` — uploads audio to your configured ASR provider, then polishes via LLM + +See [CHANGELOG.md](./CHANGELOG.md) for release history and [CONTRIBUTING.md](./CONTRIBUTING.md) for PR guidelines. + +--- + +## Adding an LLM provider + +Append a preset in `OSGKeyboardShared/Models/LLMProvider.swift` — any OpenAI-compatible `/chat/completions` endpoint works out of the box. + +--- + +## License + +[Source Available License](./LICENSE) — personal, non-commercial use only. Commercial licensing: [rocky.hk@gmail.com](mailto:rocky.hk@gmail.com). diff --git a/README.md b/README.md index 82f1a06..898f33d 100644 --- a/README.md +++ b/README.md @@ -1,217 +1,86 @@ # OSGKeyboard -> Tap to talk, tap to stop — AI-polished text appears at your cursor in any app. -> A source-available, custom-keyboard-based voice input tool for iOS 26+, inspired by [Typeless](https://typeless.com) and [OpenLess](https://github.com/Open-Less/openless). +**开口即文字。** -![Platform](https://img.shields.io/badge/platform-iOS%2026%2B-0078D4?logo=apple) +在 iPhone、iPad 和 Mac 上,用说的代替打字。任意 App 里开口,润色好的文字直接落到光标处。 + +![Platform](https://img.shields.io/badge/iOS%20%2F%20iPadOS-26%2B-0078D4?logo=apple) +![Platform](https://img.shields.io/badge/macOS-14%2B-555?logo=apple) ![Swift](https://img.shields.io/badge/Swift-6.0-FA7343?logo=swift) +![Version](https://img.shields.io/badge/version-0.5.3-3aa05a) ![License](https://img.shields.io/badge/license-Source%20Available-blue) -![CI](https://github.com/hkgood/OSGKeyboard/actions/workflows/ci.yml/badge.svg) -![Version](https://img.shields.io/badge/version-0.2.1-3aa05a) -[中文 README](./README.zh.md) · [Privacy Policy](https://hkgood.github.io/OSGKeyboard/privacy/) +[官网](https://hkgood.github.io/OSGKeyboard/) · [English](./README.en.md) · [隐私政策](https://hkgood.github.io/OSGKeyboard/privacy/) --- -## What is it? +## 为什么用它 -OSGKeyboard is a free, source-available alternative to commercial voice-input tools. It runs as a **Custom Keyboard Extension** on iOS, so you can use it in **any app** — Messages, Notes, Mail, WeChat, ChatGPT, Claude, Cursor, you name it. - -1. Tap the mic to start recording -2. Speak naturally (up to 3.5 minutes / 210 seconds per take) -3. Tap again to stop — the AI polishes your words into clean text and inserts at the cursor - -By default, audio is transcribed **on-device** by Apple's `SpeechAnalyzer` + `DictationTranscriber` (iOS 26+) — **no audio leaves your phone** unless you say so. If polish is enabled, only the transcript text goes to your chosen LLM. Optionally, you can switch to a **cloud ASR engine** (explicit opt-in with a confirmation): in that mode your recordings are uploaded to the ASR provider you configure. - -Under the hood, OSGKeyboard uses a **Flow session model**: a long-lived audio session runs in the host app, the keyboard extension writes tiny "start / stop" signals to the App Group, and the polished text is delivered back to the keyboard for insertion. You do not need to jump back to the host app between recordings. +- **真的随处可用** — 微信、备忘录、Notion、Cursor、邮件……光标在哪,文字就落在哪 +- **说完就能用** — 点按(iOS)或按住 Option(Mac)开口,AI 自动补标点、整理结构,不用自己改稿 +- **默认不上传录音** — iOS 本地识别、Mac 可选本地模型;只有你主动开启云端引擎时,音频才会离开设备 +- **模型随你选** — 内置润色开箱即用;也可接入 DeepSeek、OpenAI、Anthropic、OpenRouter 等任意兼容 API +- **Mac 也能全局听写** — 菜单栏常驻,屏幕底部浮层实时反馈,说完自动插入当前 App --- -## Features +## 三步开始 -- 🎙 **Tap-to-toggle recording** with a Typeless-style circular mic button, 3.5-minute (210s) per-take cap with live countdown -- 🧠 **On-device ASR** (`SpeechAnalyzer` + `DictationTranscriber`, iOS 26+) -- ✍️ **AI polishing** — adds structure, punctuation, fixes grammar, optionally produces lists -- 🧩 **Local + cloud polish toggle** — local engine is ASR-only by default; opt into a post-ASR cloud polish step (DeepSeek by default) when the iOS speech recognition isn't strong enough for your environment (noisy far-field audio, strong accents, etc.) -- 🔌 **Bring-your-own API** — works with any OpenAI-compatible endpoint (OpenAI, DeepSeek, Qwen DashScope, Moonshot, Zhipu, your own self-hosted server, …) -- 🔒 **Privacy first** — on-device ASR by default, so audio never leaves your device unless you explicitly opt into the cloud engine; polish sends only the transcript to the LLM you choose -- 🎨 **Native SwiftUI** — dark theme, frosted glass, pure Swift 6, ~3,600 lines of code -- 🪶 **Zero dependencies** — no SwiftPM packages, no CocoaPods, no Carthage -- 🔁 **Flow session** — keep recording across multiple takes without bouncing back to the host app +1. **安装并授权** — iOS 添加键盘并开启「完全访问」;Mac 授予麦克风与辅助功能 +2. **选引擎** — 本地识别 + 内置润色(零配置),或填入自己的 API Key +3. **开口说话** — 切换到 OSGKeyboard 键盘,或按住 Option 键,文字即出现 + +> iOS 首次打开会走 6 步引导:权限 → 键盘 → 识别引擎 → 润色模型,约 2 分钟完成。 --- -## Quick start +## 核心能力 -### Requirements +| | iOS / iPadOS | macOS | +|---|:---:|:---:| +| 自定义键盘 / 全局热键 | ✅ | ✅ Option 按住说话 | +| 本地语音识别 | ✅ Apple SpeechAnalyzer | ✅ SenseVoice / Qwen3 | +| AI 文本润色 | ✅ | ✅ | +| 润色后翻译 | ✅ | ✅ | +| 个性词库 | ✅ iCloud 同步 | ✅ | +| 听写历史 | ✅ | ✅ | +| 灵动岛 / 听写浮层 | ✅ Live Activity | ✅ 底部胶囊浮层 | -- macOS with **Xcode 26** (matches `project.yml` deployment target iOS 26) -- iPhone or iPad running **iOS 26.0+** (iPad fully supported: Split View / Stage Manager, adaptive layout) -- [XcodeGen](https://github.com/yonaskolb/XcodeGen): `brew install xcodegen` -- An OpenAI-compatible API key (e.g. from [OpenAI](https://platform.openai.com/api-keys), [DeepSeek](https://platform.deepseek.com/api_keys), or [Qwen DashScope](https://dashscope.console.aliyun.com/apiKey)). Not needed if you stay on the "local ASR only" engine. +--- -### Build & run +## 隐私 + +- **默认本地识别** — 录音在设备上转写,不经过我们的服务器 +- **润色只发文字** — 发给 LLM 的是转写文本,不是原始音频 +- **不记录击键** — 键盘扩展不采集、不上传你的日常输入内容 +- 详见 [隐私政策](https://hkgood.github.io/OSGKeyboard/privacy/) + +--- + +## 获取 + +**从源码构建**(需 macOS + Xcode 26): ```bash git clone https://github.com/hkgood/OSGKeyboard.git cd OSGKeyboard -./Scripts/generate-xcodeproj.sh # generates OSGKeyboard.xcodeproj via XcodeGen -open OSGKeyboard.xcodeproj # or build via CLI: -xcodebuild -project OSGKeyboard.xcodeproj -scheme OSGKeyboard \ - -destination 'generic/platform=iOS Simulator' build +./Scripts/generate-xcodeproj.sh +open OSGKeyboard.xcodeproj ``` -> The `OSGKeyboard.xcodeproj` is **not** committed — it is regenerated from -> `project.yml` by `Scripts/generate-xcodeproj.sh`. Always re-run the script -> after `git pull` if `project.yml` has changed. +- iOS:选择 `OSGKeyboard` scheme,跑在 iPhone / iPad 模拟器或真机 +- macOS:选择 `OSGKeyboardMac` scheme,编译产物为 `OSGKeyboard.app` -### macOS distribution (decision) - -The macOS menu-bar app ships via **Developer ID direct distribution** (notarized, -non-sandboxed), NOT the Mac App Store. This is deliberate: its core features — -global hold-to-talk hotkey, Accessibility-based text insertion into other apps, -and synthesized ⌘V — are incompatible with the Mac App Store sandbox, and a -sandboxed Accessibility grant also tends to reset after every app update. -`OSGKeyboardMac.entitlements` therefore keeps `com.apple.security.app-sandbox` -set to `false`; do not flip it back on without redesigning the insertion path. -(The iOS app targets the iOS App Store as usual — see `AUDIT_APPSTORE.md`.) - -### Enable the keyboard in iOS - -The host app walks you through a **5-step onboarding**: - -1. **Welcome** — intro to OSGKeyboard -2. **Microphone** — request mic access -3. **Speech recognition** — request on-device speech recognition access -4. **Enable keyboard + Full Access** — open iOS Settings to add OSGKeyboard and allow Full Access -5. **Engine + API** — pick the local or cloud engine, then paste your API key (cloud / cloud-polish only) - -After onboarding, in any text field, tap 🌐 to switch to **OSGKeyboard**, then tap the circular mic to start, speak, and tap again to stop. - -> **"Allow Full Access" is required.** Without it, iOS blocks the keyboard from using the microphone and from making network requests. We never log, store, or transmit your keystrokes — see [`PrivacyInfo.xcprivacy`](./OSGKeyboard/PrivacyInfo.xcprivacy) and our [Privacy Policy](https://hkgood.github.io/OSGKeyboard/privacy/). +开发细节、架构说明与贡献指南见 [README.en.md](./README.en.md) 与 [CONTRIBUTING.md](./CONTRIBUTING.md)。 --- -## Architecture +## 许可 -``` -OSGKeyboard/ -├── OSGKeyboard/ # Main iOS app (host of the Flow session) -│ ├── Services/ # FlowSessionManager, AppPermissions, SpeechHistoryStore, … -│ ├── Views/ # SwiftUI: OnboardingView, HomeView, SettingsView, HistoryView, … -│ ├── OSGKeyboardApp.swift # @main entry, owns the FlowSessionManager -│ ├── PrivacyInfo.xcprivacy # Required privacy manifest -│ └── OSGKeyboard.entitlements # App Group + Keychain Group -├── OSGKeyboardExt/ # Custom Keyboard Extension -│ ├── KeyboardViewController.swift # Principal class (drives SwiftUI) -│ ├── Services/ # AppGroupPersistor, HostAppLauncher, AudioCaptureService (legacy, unused) -│ ├── Views/ # KeyboardRootView, RecordButton, WaveformView -│ └── PrivacyInfo.xcprivacy -├── OSGKeyboardShared/ # Framework shared by app + extension (APPLICATION_EXTENSION_API_ONLY=YES) -│ ├── Services/ # FlowSessionBridge, FlowSessionDarwin, LLMClient, PolishingService, ASRService, Keychain, AppGroupStore, … -│ ├── Models/ # LLMProvider, ProviderConfig, TranscriptionDelivery, AudioBufferSnapshot, … -│ ├── DesignSystem/ # Theme, ThemedRoot -│ └── Constants/ # AppGroup identifier -├── OSGKeyboardTests/ # XCTest unit tests (LLM, Keychain, ASR, Flow bridge, …) -├── OSGKeyboardExtTests/ # Keyboard-extension-side unit tests -├── Scripts/ # generate-xcodeproj.sh, patch-icon-composer.sh -├── docs/ # GitHub Pages site (privacy policy + landing) -├── project.yml # XcodeGen project definition (source of truth) -└── .github/workflows/ci.yml # Lint + build CI -``` - -### Data flow — Flow session model - -``` -[Tap mic in keyboard] - └─► KeyboardViewController.pressBegan - └─► FlowSessionBridge.setRecordingState(.recording) [App Group UserDefaults] - └─► Darwin notification: "recordingState changed" - └─► FlowSessionManager (host app) sees the signal - └─► FlowContinuousCapture feeds 16 kHz PCM into ChunkedUtterancePipeline - └─► ASRService.transcribe (iOS 26 SpeechAnalyzer) - └─► ASREvent.partial / .final - └─► UtteranceTranscriptStitcher stitches the chunks - └─► PolishingService (LLMClient) [optional, configurable] - └─► FlowSessionBridge.storeTranscriptionResult -[Keyboard polls + Darwin notif] - └─► KeyboardViewController sees the result - └─► textDocumentProxy.insertText(polished) -``` - -**Engine modes:** - -- `local` (default) — on-device ASR via `SpeechAnalyzer`; transcript is inserted as-is. No network round-trip. -- `local` + "Cloud polish after ASR" toggle (Settings → Engine) — same on-device ASR, but the transcript (text only) is routed through your configured LLM before insertion. Useful when iOS speech recognition isn't accurate enough in your environment. -- `cloud` (opt-in, requires an explicit confirmation) — **your voice recordings are uploaded** to the ASR provider you configure (e.g. OpenAI `/audio/transcriptions`, DashScope, Zhipu), and the resulting transcript is sent to your LLM for polish. Choose this only when you accept your provider's privacy terms. - -**Cross-process plumbing (host app ↔ keyboard extension):** - -- **App Group `group.com.osgkeyboard.shared`** — `UserDefaults` for the live Flow session state, recording state, audio levels, transcription delivery, and most preferences. -- **Shared Keychain group `com.osgkeyboard.shared`** — the LLM API key is written by the host app's Settings, read by both processes before every LLM call. -- **Darwin notifications (`CFNotificationCenter`)** — light-weight "something changed" pings; payloads still travel through the App Group. +[源码可见许可](./LICENSE) — 个人学习与非商用本地使用;商用请联系 [rocky.hk@gmail.com](mailto:rocky.hk@gmail.com)。 --- -## Adding a new LLM provider - -Open `OSGKeyboardShared/Models/LLMProvider.swift` and append a new `LLMProvider` to the `presets` array. The default `OpenAICompatibleClient` handles any endpoint that speaks the `POST /chat/completions` protocol. - -```swift -LLMProvider( - id: "groq", - name: "Groq", - defaultBaseURL: "https://api.groq.com/openai/v1", - defaultModel: "llama-3.1-70b-versatile", - apiKeyURL: URL(string: "https://console.groq.com/keys") -) -``` - -That's it — no other code changes required. - -To set it as the new default for first-time users, also bump the `defaultProviderId` constant used by `ProviderConfig`. - ---- - -## Known limitations - -- **iOS 26+ only.** Earlier iOS versions are not supported. We dropped the pre-26 SFSpeechRecognizer / AVAudioSession branching so the entire ASR path can use the iOS 26 `SpeechAnalyzer` API exclusively. -- **~60 MB memory cap** for the keyboard extension (iOS sandbox). The Flow session is hosted in the main app, so audio buffers and ASR models live there, not in the extension. -- **"Allow Full Access" required.** Without it, the keyboard can't reach the microphone or make network requests for cloud polish. -- **Password fields and some `WKWebView` textareas** are blocked by iOS itself — not something we can work around. -- **3.5-minute (210s) per-take cap.** A long take is automatically stopped and dispatched for transcription; a new take can be started immediately. -- **Force-quitting the host app does not resurrect the old session.** The Live Activity is cleared immediately; the next time you open the app (with permissions granted) a fresh voice session starts automatically. -- **3-minute per-utterance ASR cap.** If you exceed it, the pipeline gracefully splits into multiple stitched chunks. -- **No on-device LLM polish.** The local engine is ASR-only; "AI polish" is always cloud-based and configurable. On-device model support was explored in v0.2.0 and rolled back in v0.2.1 to keep the dependency surface at zero SPM packages. -- **URL scheme `osgkeyboard://`** can be opened by any app on the device. We don't trust it for anything beyond "wake the host app and (re)start the Flow session"; it never carries your API key or other secrets. - ---- - -## Development - -- **Build setup** — see the [Build Setup](#build-setup) section at the top of this file. Run `./Scripts/generate-xcodeproj.sh` after any `project.yml` change. -- **Tests** — `xcodebuild test -project OSGKeyboard.xcodeproj -scheme OSGKeyboard -destination 'platform=iOS Simulator,name=iPhone 17'` runs both `OSGKeyboardTests` and `OSGKeyboardExtTests` targets. -- **CI** — `.github/workflows/ci.yml` runs SwiftLint, a clean Debug build, and the test suite on every push to `0.1` / `0.2` and PRs. -- **Logging** — `print` is debug-only; release builds use `NSLog` for the few cross-process status messages. - ---- - -## Project status - -- **Current release: v0.2.1** (2026-06-24) -- **Default branch: `0.2`** (renamed from `main` on 2026-06-24; the previous `main` is preserved as `0.1`). -- See [`CHANGELOG.md`](./CHANGELOG.md) for the full release history and [`TYPEWHISPER_FLOW_MIGRATION_TRACKER.md`](./TYPEWHISPER_FLOW_MIGRATION_TRACKER.md) for the architecture-decision log behind the Flow session model. - ---- - -## License - -[OSGKeyboard Source Available License](./LICENSE) — personal learning and non-commercial local use only. No commercial use, redistribution, or public forks without permission. Commercial licensing: [rocky.hk@gmail.com](mailto:rocky.hk@gmail.com). - ---- - -## Acknowledgements - -- Inspired by [Typeless](https://typeless.com) and the desktop open-source [OpenLess](https://github.com/Open-Less/openless) -- Built with [XcodeGen](https://github.com/yonaskolb/XcodeGen) -- Powered by Apple's [SpeechAnalyzer](https://developer.apple.com/documentation/speech/speechanalyzer) and [SFSpeechRecognizer](https://developer.apple.com/documentation/speech/sfspeechrecognizer) +

+ 灵感来自 Typeless · 端侧识别基于 Apple SpeechAnalyzer · Mac 本地模型基于 Sherpa-ONNX +

diff --git a/README.zh.md b/README.zh.md index 7723c37..1984f63 100644 --- a/README.zh.md +++ b/README.zh.md @@ -1,203 +1,3 @@ # OSGKeyboard -> 按一下开始,再按一下结束 —— AI 润色文字直接出现在任意 App 的光标处。 -> 一款源码可见的 iOS 自定义键盘语音输入工具,灵感来自 [Typeless](https://typeless.com) 和 [OpenLess](https://github.com/Open-Less/openless)。 - -![Platform](https://img.shields.io/badge/platform-iOS%2026%2B-0078D4?logo=apple) -![Swift](https://img.shields.io/badge/Swift-6.0-FA7343?logo=swift) -![License](https://img.shields.io/badge/license-Source%20Available-blue) -![Version](https://img.shields.io/badge/version-0.2.1-3aa05a) - -[English README](./README.md) · [隐私政策](https://hkgood.github.io/OSGKeyboard/privacy/) - ---- - -## 这是什么? - -OSGKeyboard 是商业语音输入工具的免费、源码可见替代方案。它以 **iOS 自定义键盘扩展** 的形式运行,所以你可以在 **任何 App** 里使用 —— 微信、备忘录、邮件、ChatGPT、Claude、Cursor,无所不能。 - -1. 按下麦克风键开始录音 -2. 自由说话(单次上限 3.5 分钟 / 210 秒) -3. 再按一下结束 —— AI 自动整理成干净的文字并插入光标 - -默认情况下,**音频在设备本地转写**(iOS 26+ 的 `SpeechAnalyzer` + `DictationTranscriber`)——**除非你主动选择,音频不会离开你的手机**;开启润色时也只有文本会发到你选择的 LLM。你也可以显式切换到**云端识别引擎**(需二次确认的 opt-in):该模式下你的录音会上传到你配置的识别服务商。 - -项目内部采用 **Flow 会话模型**:主 App 维护一个长生命周期的音频会话,键盘扩展只通过 App Group 写入"开始 / 停止"等轻量信号,润色后的文本再由主 App 回传给键盘插入。**多次录音之间无需反复跳回主 App**。 - ---- - -## 特性 - -- 🎙 **点按录音** —— Typeless 风格的圆形麦克风按钮,单次上限 3.5 分钟(210 秒)并实时倒计时 -- 🧠 **端侧 ASR**(iOS 26+ `SpeechAnalyzer` + `DictationTranscriber`) -- ✍️ **AI 润色** —— 自动加结构、补标点、修正语法、可生成列表 -- 🧩 **本地 + 云端润色开关** —— 本地模式默认仅在设备上识别;若 iOS 语音识别效果不理想(远场、噪声、方言),可开启「识别后云端润色」,默认走 DeepSeek -- 🔌 **自带 API 接入** —— 兼容任何 OpenAI 兼容协议端点(OpenAI / DeepSeek / Qwen DashScope / Moonshot / 智谱 / 自建服务器 ……) -- 🔒 **隐私优先** —— 默认端侧识别,音频不离开设备(除非显式开启云端识别引擎);润色只发送文本给你选择的 LLM -- 🎨 **原生 SwiftUI** —— 暗色主题、毛玻璃、纯 Swift 6 实现,约 3,600 行代码 -- 🪶 **零依赖** —— 无 SwiftPM 包、无 CocoaPods、无 Carthage -- 🔁 **Flow 会话** —— 多次录音无需跳回主 App,会话自动维持心跳与续期 - ---- - -## 快速开始 - -### 环境要求 - -- macOS + **Xcode 26**(与 `project.yml` 中 iOS 26 部署目标对齐) -- iPhone 运行 **iOS 26.0+** -- [XcodeGen](https://github.com/yonaskolb/XcodeGen):`brew install xcodegen` -- 一个 OpenAI 兼容 API Key([OpenAI](https://platform.openai.com/api-keys) / [DeepSeek](https://platform.deepseek.com/api_keys) / [Qwen DashScope](https://dashscope.console.aliyun.com/apiKey) 任一)。如果一直使用「纯本地 ASR」引擎则无需 Key。 - -### 编译与运行 - -```bash -git clone https://github.com/hkgood/OSGKeyboard.git -cd OSGKeyboard -./Scripts/generate-xcodeproj.sh # 通过 XcodeGen 生成 OSGKeyboard.xcodeproj -open OSGKeyboard.xcodeproj # 或命令行编译: -xcodebuild -project OSGKeyboard.xcodeproj -scheme OSGKeyboard \ - -destination 'generic/platform=iOS Simulator' build -``` - -> `OSGKeyboard.xcodeproj` **不进入版本库**,由 `Scripts/generate-xcodeproj.sh` 从 `project.yml` 生成。每次 `git pull` 后若 `project.yml` 有变更,请重新执行该脚本。 - -### 在 iOS 中启用键盘 - -主 App 会引导你走完 **5 步**: - -1. **欢迎** —— 介绍 OSGKeyboard -2. **麦克风** —— 申请麦克风权限 -3. **语音识别** —— 申请端侧语音识别权限 -4. **启用键盘 + 完全访问** —— 跳转 iOS 设置添加 OSGKeyboard 并允许完全访问 -5. **引擎 + API** —— 选择本地或云端引擎,粘贴 API Key(仅云端 / 识别后云端润色需要) - -完成后,在任意输入框点 🌐 切换到 **OSGKeyboard**,再点圆形麦克风键开始说话,再次点击结束。 - -> **"允许完全访问"是必须的。** 没有它,iOS 会阻止键盘使用麦克风与网络。我们**绝不记录、存储或上传你的击键** —— 见 [`PrivacyInfo.xcprivacy`](./OSGKeyboard/PrivacyInfo.xcprivacy) 与 [隐私政策](https://hkgood.github.io/OSGKeyboard/privacy/)。 - ---- - -## 架构 - -``` -OSGKeyboard/ -├── OSGKeyboard/ # 主 iOS App(Flow 会话宿主) -│ ├── Services/ # FlowSessionManager、AppPermissions、SpeechHistoryStore、… -│ ├── Views/ # SwiftUI:OnboardingView、HomeView、SettingsView、HistoryView、… -│ ├── OSGKeyboardApp.swift # @main 入口,持有 FlowSessionManager -│ ├── PrivacyInfo.xcprivacy # 隐私清单 -│ └── OSGKeyboard.entitlements # App Group + Keychain Group -├── OSGKeyboardExt/ # 自定义键盘扩展 -│ ├── KeyboardViewController.swift # 主体类(驱动 SwiftUI) -│ ├── Services/ # AppGroupPersistor、HostAppLauncher、AudioCaptureService(旧版,未使用) -│ ├── Views/ # KeyboardRootView、RecordButton、WaveformView -│ └── PrivacyInfo.xcprivacy -├── OSGKeyboardShared/ # 主 App + 键盘共享 framework(APPLICATION_EXTENSION_API_ONLY=YES) -│ ├── Services/ # FlowSessionBridge、FlowSessionDarwin、LLMClient、PolishingService、ASRService、Keychain、AppGroupStore、… -│ ├── Models/ # LLMProvider、ProviderConfig、TranscriptionDelivery、AudioBufferSnapshot、… -│ ├── DesignSystem/ # Theme、ThemedRoot -│ └── Constants/ # AppGroup ID -├── OSGKeyboardTests/ # XCTest 单元测试(LLM、Keychain、ASR、Flow bridge、…) -├── OSGKeyboardExtTests/ # 键盘扩展侧单元测试 -├── Scripts/ # generate-xcodeproj.sh、patch-icon-composer.sh -├── docs/ # GitHub Pages(隐私政策 + 落地页) -├── project.yml # XcodeGen 工程定义(唯一源) -└── .github/workflows/ci.yml # Lint + 编译 CI -``` - -### 数据流 —— Flow 会话模型 - -``` -[键盘点按麦克风] - └─► KeyboardViewController.pressBegan - └─► FlowSessionBridge.setRecordingState(.recording) [App Group UserDefaults] - └─► Darwin 通知:"recordingState changed" - └─► 主 App 的 FlowSessionManager 收到信号 - └─► FlowContinuousCapture 持续把 16kHz PCM 喂给 ChunkedUtterancePipeline - └─► ASRService.transcribe(iOS 26 SpeechAnalyzer) - └─► ASREvent.partial / .final - └─► UtteranceTranscriptStitcher 拼接 - └─► PolishingService(LLMClient) [可选,由引擎模式决定] - └─► FlowSessionBridge.storeTranscriptionResult -[键盘轮询 + Darwin 通知] - └─► KeyboardViewController 拿到结果 - └─► textDocumentProxy.insertText(润色后文本) -``` - -**引擎模式:** - -- `local`(默认)—— 仅端侧 `SpeechAnalyzer` 识别,原始文本直接插入,不联网。 -- `local` + 「识别后云端润色」开关(设置 → 引擎)—— 同样走端侧 ASR,但识别完成后送 LLM 润色(仅文本)再插入。适用于 iOS 识别效果不理想的场景。 -- `cloud`(opt-in,需显式确认)—— **你的语音录音会上传**到你配置的识别服务商(如 OpenAI `/audio/transcriptions`、DashScope、智谱),识别文本再送 LLM 润色。请在接受服务商隐私条款的前提下选用。 - -**跨进程管道(主 App ↔ 键盘扩展):** - -- **App Group `group.com.osgkeyboard.shared`** —— `UserDefaults` 存放 Flow 会话状态、录音状态、音量、转写投递、绝大部分偏好。 -- **共享 Keychain 组 `com.osgkeyboard.shared`** —— LLM API Key 由主 App「设置」写入,键盘扩展在每次 LLM 调用前读取。 -- **Darwin 通知(`CFNotificationCenter`)** —— 轻量级"有变化"信号;具体负载仍走 App Group。 - ---- - -## 新增 LLM 提供商 - -打开 `OSGKeyboardShared/Models/LLMProvider.swift`,在 `presets` 数组里追加一条 `LLMProvider` 即可。默认的 `OpenAICompatibleClient` 处理任何实现了 `POST /chat/completions` 的端点。 - -```swift -LLMProvider( - id: "groq", - name: "Groq", - defaultBaseURL: "https://api.groq.com/openai/v1", - defaultModel: "llama-3.1-70b-versatile", - apiKeyURL: URL(string: "https://console.groq.com/keys") -) -``` - -仅此而已,**无需改动其他代码**。 - -如需设为新用户的默认值,还需同步调整 `ProviderConfig` 中的 `defaultProviderId` 常量。 - ---- - -## 已知限制 - -- **仅支持 iOS 26+。** 我们已移除 26 以下 `SFSpeechRecognizer` / `AVAudioSession` 的兼容分支,让 ASR 路径全部走 iOS 26 `SpeechAnalyzer`。 -- **键盘扩展约 60 MB 内存上限**(iOS 沙盒)。Flow 会话由主 App 承载,音频缓冲与 ASR 模型都在主 App 侧,不占用扩展内存。 -- **必须「允许完全访问」**。否则键盘无法使用麦克风,也无法发起云端润色请求。 -- **密码框与部分 `WKWebView` 输入框不可用**(iOS 系统限制,无法绕过)。 -- **单次录音上限 3.5 分钟(210 秒)**。到点自动停止并提交识别,下次可立即开始新的录音。 -- **杀掉主 App 后不复活旧会话**。灵动岛会立即清理;下次回到主 App 时(权限齐全)自动开启新的语音会话。 -- **单次 utterance ASR 上限 3 分钟**。超出后会拆成多个 chunk 拼接识别。 -- **不做端侧 LLM 润色**。本地引擎仅做 ASR;"AI 润色"始终走云端、可配置。v0.2.0 曾尝试引入端侧模型,v0.2.1 回滚以保持零 SPM 依赖。 -- **URL Scheme `osgkeyboard://`** 任何 App 都可调用。OSGKeyboard 只把它用于"唤醒主 App / 续期 Flow 会话",**不** 传递 API Key 等敏感信息。 - ---- - -## 开发指南 - -- **构建** —— 见本文开头的 [编译与运行](#编译与运行) 节。`project.yml` 变更后请重新执行 `./Scripts/generate-xcodeproj.sh`。 -- **测试** —— `xcodebuild test -project OSGKeyboard.xcodeproj -scheme OSGKeyboard -destination 'platform=iOS Simulator,name=iPhone 17'` 会同时跑 `OSGKeyboardTests` 与 `OSGKeyboardExtTests` 两个 target。 -- **CI** —— `.github/workflows/ci.yml` 在每次 push 到 `0.1` / `0.2` 分支及 PR 时跑 SwiftLint、Debug 干净构建和测试套件。 -- **日志** —— `print` 仅在 Debug 启用;Release 仅保留少量跨进程状态相关的 `NSLog`。 - ---- - -## 项目状态 - -- **当前版本:v0.2.1**(2026-06-24) -- **默认分支:`0.2`**(2026-06-24 从 `main` 改名;旧 `main` 保留为 `0.1`)。 -- 完整发布记录见 [`CHANGELOG.md`](./CHANGELOG.md);Flow 会话模型的架构决策日志见 [`TYPEWHISPER_FLOW_MIGRATION_TRACKER.md`](./TYPEWHISPER_FLOW_MIGRATION_TRACKER.md)。 - ---- - -## 许可 - -[OSGKeyboard 源码可见许可协议](./LICENSE) —— 仅限个人学习与非商用本地使用;禁止商用、再分发及公开 fork。商业授权请联系 [rocky.hk@gmail.com](mailto:rocky.hk@gmail.com)。 - ---- - -## 致谢 - -- 灵感来源:[Typeless](https://typeless.com) 与桌面端开源版 [OpenLess](https://github.com/Open-Less/openless) -- 工程脚手架:[XcodeGen](https://github.com/yonaskolb/XcodeGen) -- 端侧 ASR:Apple [SpeechAnalyzer](https://developer.apple.com/documentation/speech/speechanalyzer) / [SFSpeechRecognizer](https://developer.apple.com/documentation/speech/sfspeechrecognizer) +中文说明已合并至主文档:[README.md](./README.md) diff --git a/docs/index.html b/docs/index.html index 896f5d6..543e4e9 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,13 +1,13 @@ - + - OSGKeyboard — On-device voice-to-text keyboard for iOS - - + OSGKeyboard — 开口即文字 · iOS & Mac 语音输入 + + @@ -18,8 +18,8 @@ - - + + @@ -29,8 +29,8 @@ - - + + @@ -39,22 +39,22 @@ "@context": "https://schema.org", "@type": "SoftwareApplication", "name": "OSGKeyboard", - "operatingSystem": "iOS 26.0 or later", + "operatingSystem": "iOS 26.0 or later, macOS 14.0 or later", "applicationCategory": "UtilitiesApplication", "url": "https://hkgood.github.io/OSGKeyboard/", "image": "https://hkgood.github.io/OSGKeyboard/assets/app-icon.png", - "description": "A source-available iOS keyboard that turns speech into polished text in any app. On-device recognition (iOS 26+), AI polish, translation, and a personal dictionary. Local or cloud engine.", - "softwareVersion": "0.3.6", + "description": "Voice input for iPhone, iPad, and Mac. On-device recognition, AI polish, personal dictionary, and iCloud sync. Speak in any app — polished text lands at your cursor.", + "softwareVersion": "0.5.3", "isAccessibleForFree": true, "offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }, "author": { "@type": "Organization", "name": "OSGKeyboard" }, "featureList": [ - "On-device speech recognition (SpeechAnalyzer, iOS 26+)", - "AI polish with three intensity levels", - "Post-polish translation", - "Personal dictionary", - "Long-lived Flow session", - "Local or cloud engine" + "Custom keyboard for iOS and iPadOS", + "macOS global dictation with Option hotkey", + "On-device speech recognition", + "AI text polish with multiple LLM providers", + "Personal dictionary with iCloud sync", + "Post-polish translation" ] } @@ -389,6 +389,30 @@ .step h3 { margin: 0 0 0.35rem; font-size: 1.05rem; font-weight: 650; } .step p { margin: 0; color: var(--text-secondary); font-size: 0.95rem; } + /* Platforms */ + .platforms { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1.25rem; + } + @media (max-width: 640px) { .platforms { grid-template-columns: 1fr; } } + + .platform { + padding: 1.5rem; + border-radius: var(--radius); + border: 1px solid var(--line); + background: var(--bg-soft); + } + .platform-head { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 0.75rem; + } + .platform-head .material-symbols-outlined { font-size: 22px; color: var(--accent); } + .platform h3 { margin: 0; font-size: 1.05rem; font-weight: 650; } + .platform p { margin: 0; font-size: 0.93rem; color: var(--text-secondary); line-height: 1.55; } + /* Closing */ .closing { border-top: 1px solid var(--line); @@ -424,8 +448,8 @@ + +
+
+

两个平台,同一种体验

+

手机上用键盘,Mac 上按住 Option — 说完即落字。

+
+
+
+
+ +

iOS / iPadOS

+
+

自定义键盘 + Flow 会话。点按麦克风说话,连续听写不用跳 App。灵动岛显示录音状态。

+
+
+
+ +

macOS

+
+

菜单栏常驻,按住 Option 全局听写。底部浮层实时反馈,支持 SenseVoice / Qwen3 本地模型。

+
+
+
+
-

Everything you need to type by voice

-

On-device by default, refined by AI when you want it — in a single, native keyboard.

+

开口即写,所需尽在此

+

本地优先,AI 加持 — 从识别到润色,一条链路搞定。

-

Tap-to-talk dictation

-

Tap to start, tap to stop. Up to 3.5 minutes per take, with a live countdown.

+

点按听写

+

点一下开始,再点一下结束。单次最长 3.5 分钟,倒计时实时显示。

-

On-device recognition

-

iOS 26 SpeechAnalyzer transcribes on your device by default — audio is uploaded only if you explicitly enable the cloud engine.

+

本地识别

+

iOS 端侧 SpeechAnalyzer;Mac 可选 SenseVoice / Qwen3。默认不上传录音。

-

AI polish

-

Punctuation, structure, and clarity in three intensity levels. Polish sends only text — on the default engine your audio never goes online.

+

AI 润色

+

自动补标点、整理结构。内置润色开箱即用,也可接入 DeepSeek、OpenAI 等 API。

-

Built-in translation

-

Translate after polish into English, 中文, 日本語, 한국어 and more — right from the keyboard.

+

润色后翻译

+

润色完成后一键翻译为 English、中文、日本語、한국어 等。

-

Personal dictionary

-

Keep names, products, and jargon accurate. The AI preserves your terms every time.

+

个性词库

+

人名、产品名、专业术语 — AI 润色时原样保留,iCloud 多设备同步。

-
-

Flow session

-

A long-lived session keeps the recorder warm, so you can dictate take after take.

+
+

iCloud 同步

+

设置、词库、听写历史在多设备间自动同步,换机无缝衔接。

@@ -526,21 +574,21 @@
-

Three steps to your first message

-

Set up once, then dictate anywhere you type.

+

三步,开始第一句话

+

设置一次,之后在任意输入处开口即写。

-

Enable the keyboard

-

Add OSGKeyboard in iOS Settings and allow Full Access — needed for the mic and network.

+

安装并授权

+

iOS 添加键盘并开启完全访问;Mac 授予麦克风与辅助功能权限。

-

Choose your engine

-

Local: on-device ASR with built-in polish, no API key. Cloud: use your own OpenAI-compatible key.

+

选引擎与润色

+

本地识别 + 内置润色零配置可用;也可填入自己的 API Key 接入任意兼容模型。

-

Tap to talk

-

Switch to OSGKeyboard, tap the mic, speak, tap again. Polished text lands at your cursor.

+

开口说话

+

切换到 OSGKeyboard 键盘,或按住 Option 键。润色好的文字自动插入光标处。

@@ -549,13 +597,13 @@
- Privacy by design -

Your voice stays yours

-

Speech is processed on-device by default (the optional cloud engine uploads recordings to the provider you configure). We never log ordinary keystrokes; polish and translation receive only transcribed text.

+ 隐私优先 +

你的声音,始终属于你

+

默认在设备端识别,录音不上传。润色只发送转写文字,不记录日常击键。云端引擎需你主动开启,并适用所配置服务商的隐私条款。

- Read the privacy policy + 查看隐私政策
@@ -563,7 +611,7 @@