feat: macOS architecture, cloud ASR/LLM providers, and 6-step iOS onboarding

- 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
This commit is contained in:
Rocky
2026-07-11 19:10:20 +08:00
parent cdf833935a
commit cc8dd1070a
116 changed files with 6659 additions and 2634 deletions
+5
View File
@@ -73,3 +73,8 @@ __pycache__/
# Standalone experiment: 灵动岛录音机制验证 demo (throwaway) # Standalone experiment: 灵动岛录音机制验证 demo (throwaway)
AudioIntentProbe/ AudioIntentProbe/
# Local crash-log pulls and one-off diff/working notes (not for the repo)
.crash-pull/
all-changes-vs-main.patch
CHANGES.md
+5
View File
@@ -7,11 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.5.3] - 2026-07-11
### Added ### 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 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 作为按住听写键,避免与其他应用冲突。 - **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 ### 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 / 会话调试文本框已去掉。 - **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 ParaformerSenseVoice / Qwen3 0.6B / Qwen3 1.7B 分别标注速度最快 / 最平衡 / 质量最好(仍选 Paraformer 的用户迁移到 Qwen3 0.6B)。 - **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 ParaformerSenseVoice / Qwen3 0.6B / Qwen3 1.7B 分别标注速度最快 / 最平衡 / 质量最好(仍选 Paraformer 的用户迁移到 Qwen3 0.6B)。
- **macOS visual system**: full-app redesign around the brand line “Speak it. Its 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. Its typed.” 做全 App 设计升级——侧栏分组、克制的选中态、首页不对称统计(字数主卡)、统一页头、降权状态栏,以及更清晰的暗色卡片层次。 - **macOS visual system**: full-app redesign around the brand line “Speak it. Its 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. Its 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 以保证控件排版。 - **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 ### 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 之前先清除。 - **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<asr_text>…`) 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<asr_text>…`)会剥到真实口语文案;不完整输出停在单词 `language` 时按空结果处理,不再插入。 - **macOS Qwen3 “language” garbage transcript**: Sherpa Qwen3 results that still include the model scaffold (`language Chinese<asr_text>…`) 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<asr_text>…`)会剥到真实口语文案;不完整输出停在单词 `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": "", ...}`)当作转写文本插入,现在会正确提示「没有识别到语音」。 - **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": "", ...}`)当作转写文本插入,现在会正确提示「没有识别到语音」。
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "anthropic.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>

After

Width:  |  Height:  |  Size: 368 B

+16
View File
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "ark.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Volcengine</title><path d="M7.29 5.36L3.148 21.737a.215.215 0 00.203.261h8.29a.214.214 0 00.215-.261L7.7 5.359a.214.214 0 00-.41 0z" fill-opacity=".5"></path><path clip-rule="evenodd" d="M4.553 16.18l-1.406 5.558a.214.214 0 00.203.261h2.42-4.551a.214.214 0 01-.214-.26l2.275-8.961a.214.214 0 01.409 0l.864 3.402z"></path><path d="M14.44.15a.214.214 0 00-.41 0L8.366 21.739a.214.214 0 00.214.261H19.9a.214.214 0 00.215-.261L14.44.151z" fill-opacity=".5"></path><path clip-rule="evenodd" d="M16.694 22h3.207a.215.215 0 00.214-.262l-1.839-6.993 1.164-4.592a.214.214 0 01.411 0l2.951 11.586a.214.214 0 01-.214.261h-5.894z"></path><path d="M10.278 7.741L6.685 21.736a.214.214 0 00.214.264h7.17a.216.216 0 00.214-.166.216.216 0 000-.098L10.687 7.742a.214.214 0 00-.409 0z"></path></svg>

After

Width:  |  Height:  |  Size: 944 B

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "codingplanx.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M8.5 6.5 3 12l5.5 5.5 1.4-1.4L5.8 12l4.1-4.1-1.4-1.4Zm7 0-1.4 1.4L18.2 12l-4.1 4.1 1.4 1.4L21 12l-5.5-5.5Z"/></svg>

After

Width:  |  Height:  |  Size: 205 B

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "cometapi.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>CometAPI</title><path d="M7.754 3.248C9.483.97 12.144-.223 14.99.035c4.67.422 8.023 4.694 7.27 9.384-.266 1.667-1 3.125-2.203 4.374-.468.487-1.025.9-1.662 1.422-2.554 2.09-6.026 4.854-10.413 8.294-.224.176-.669.495-.94.49a.19.19 0 01-.137-.06.192.192 0 01-.05-.14c.01-.207.077-.473.202-.8.04-.108.44-.956 1.197-2.545a1.99 1.99 0 00.179-.577.143.143 0 00-.007-.068.142.142 0 00-.098-.09.144.144 0 00-.07 0 1.479 1.479 0 00-.505.237c-.414.288-.86.648-1.337 1.078-.506.453-1.137 1.025-1.895 1.716a8.873 8.873 0 01-1.252.977.155.155 0 01-.064.021.152.152 0 01-.123-.04.154.154 0 01-.037-.055c-.027-.067-.024-.165.01-.292.113-.423.283-.902.511-1.437.17-.396.52-1.206 1.051-2.428.17-.39.697-1.592.61-1.897a.167.167 0 00-.102-.111.166.166 0 00-.15.018c-.284.194-.593.485-.93.87-.782.895-1.569 1.78-2.358 2.657-.248.274-.477.388-.687.343v-.238c.058-.215.104-.438.178-.642C4.075 12.378 5.938 7.2 6.764 4.964c.198-.537.529-1.11.99-1.716zm6.49-1.771a6.641 6.641 0 100 13.283 6.641 6.641 0 000-13.283z"></path><path d="M14.244 3.104a5.017 5.017 0 11-.002 10.033 5.017 5.017 0 01.002-10.033zm2.049 1.695a3.087 3.087 0 00-4.363 1.187 1.583 1.583 0 00-.165.442c-.015.067-.027.13-.033.194a1.308 1.308 0 00.078.56c.025.07.056.137.091.203.287.529.884.944 1.43 1.288.135.086.269.167.393.245l.392.246c.343.212.72.43 1.102.568.305.112.613.173.908.142a1.34 1.34 0 00.535-.178c.103-.061.202-.14.298-.237.064-.065.127-.137.186-.22a3.133 3.133 0 00.445-.868 3.08 3.08 0 00.056-1.71A3.063 3.063 0 0016.293 4.8z"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "gemini.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z"></path></svg>

After

Width:  |  Height:  |  Size: 651 B

+16
View File
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "groq.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Groq</title><path d="M12.036 2c-3.853-.035-7 3-7.036 6.781-.035 3.782 3.055 6.872 6.908 6.907h2.42v-2.566h-2.292c-2.407.028-4.38-1.866-4.408-4.23-.029-2.362 1.901-4.298 4.308-4.326h.1c2.407 0 4.358 1.915 4.365 4.278v6.305c0 2.342-1.944 4.25-4.323 4.279a4.375 4.375 0 01-3.033-1.252l-1.851 1.818A7 7 0 0012.029 22h.092c3.803-.056 6.858-3.083 6.879-6.816v-6.5C18.907 4.963 15.817 2 12.036 2z"></path></svg>

After

Width:  |  Height:  |  Size: 568 B

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "minimax.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "mistral.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Mistral</title><path clip-rule="evenodd" d="M3.428 3.4h3.429v3.428h3.429v3.429h-.002 3.431V6.828h3.427V3.4h3.43v13.714H24v3.429H13.714v-3.428h-3.428v-3.429h-3.43v3.428h3.43v3.429H0v-3.429h3.428V3.4zm10.286 13.715h3.428v-3.429h-3.427v3.429z"></path></svg>

After

Width:  |  Height:  |  Size: 418 B

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "openrouter.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>OpenRouter</title><path d="M16.778 1.844v1.919q-.569-.026-1.138-.032-.708-.008-1.415.037c-1.93.126-4.023.728-6.149 2.237-2.911 2.066-2.731 1.95-4.14 2.75-.396.223-1.342.574-2.185.798-.841.225-1.753.333-1.751.333v4.229s.768.108 1.61.333c.842.224 1.789.575 2.185.799 1.41.798 1.228.683 4.14 2.75 2.126 1.509 4.22 2.11 6.148 2.236.88.058 1.716.041 2.555.005v1.918l7.222-4.168-7.222-4.17v2.176c-.86.038-1.611.065-2.278.021-1.364-.09-2.417-.357-3.979-1.465-2.244-1.593-2.866-2.027-3.68-2.508.889-.518 1.449-.906 3.822-2.59 1.56-1.109 2.614-1.377 3.978-1.466.667-.044 1.418-.017 2.278.02v2.176L24 6.014Z"/></svg>

After

Width:  |  Height:  |  Size: 704 B

@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "siliconflow.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>SiliconCloud</title><path clip-rule="evenodd" d="M22.956 6.521H12.522c-.577 0-1.044.468-1.044 1.044v3.13c0 .577-.466 1.044-1.043 1.044H1.044c-.577 0-1.044.467-1.044 1.044v4.174C0 17.533.467 18 1.044 18h10.434c.577 0 1.044-.467 1.044-1.043v-3.13c0-.578.466-1.044 1.043-1.044h9.391c.577 0 1.044-.467 1.044-1.044V7.565c0-.576-.467-1.044-1.044-1.044z"></path></svg>

After

Width:  |  Height:  |  Size: 525 B

+16
View File
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "xai.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M6.469 8.776L16.512 23h-4.464L2.005 8.776H6.47zm-.004 7.9l2.233 3.164L6.467 23H2l4.465-6.324zM22 2.582V23h-3.659V7.764L22 2.582zM22 1l-9.952 14.095-2.233-3.163L17.533 1H22z"></path></svg>

After

Width:  |  Height:  |  Size: 372 B

+48 -9
View File
@@ -138,21 +138,43 @@ final class FlowSessionManager: ObservableObject {
return 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 { if coldStart {
isColdStartHandoff = true isColdStartHandoff = true
showColdStartPreparing() showColdStartPreparing()
} }
reconcilePersistedFlowStateBeforeStart()
if isActive { if isActive {
extendSession(duration: duration) extendSession(duration: duration)
refreshHostReady() refreshHostReady()
if coldStart {
Task { @MainActor [weak self] in
await self?.prepareExistingSessionForColdStartReturn()
}
}
return return
} }
@@ -1354,8 +1376,9 @@ final class FlowSessionManager: ObservableObject {
if Task.isCancelled { if Task.isCancelled {
throw CancellationError() throw CancellationError()
} }
let polished = try await polisher.polish( let polished = try await Self.polishWithHostTimeout(
text, polisher: polisher,
text: text,
mode: polishMode, mode: polishMode,
providerIdOverride: pipelineStore.polishProviderIdOverride providerIdOverride: pipelineStore.polishProviderIdOverride
) )
@@ -1542,6 +1565,22 @@ final class FlowSessionManager: ObservableObject {
return FlowSessionKeys.cloudASRWaitTimeout 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) // MARK: - Level publishing (main thread only)
private func startLevelPublishing() { private func startLevelPublishing() {
@@ -4,12 +4,10 @@
// Single source of truth for third-party open-source components shipped // Single source of truth for third-party open-source components shipped
// with OSGKeyboard. Consumed by Settings About Third-Party Licenses. // 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) // iOS targets remain zero-SPM. macOS local ASR downloads the sherpa-onnx
// and the `aufklarer/Qwen3-ASR-CoreML` runtime artefact. The local // runtime binary on demand; model weights are cached under Application Support.
// engine now ships with iOS 26 `SpeechAnalyzer` + `DictationTranscriber`
// and has no on-device ML dependencies of our own.
import Foundation import Foundation
@@ -26,19 +24,24 @@ enum OpenSourceLicenseCatalog {
let licenseText: String let licenseText: String
} }
/// Bundled libraries referenced by the app. v0.2.0 no longer pulls in /// Bundled libraries and runtime components referenced by the app.
/// `soniqo/speech-swift` (we use iOS 26 `SpeechAnalyzer` instead) and
/// no longer downloads `Qwen3-ASR-CoreML` weights both entries are
/// intentionally absent.
static let entries: [Entry] = [ static let entries: [Entry] = [
.init( .init(
id: "material-icons", id: "material-icons",
name: "Google Material Icons", name: "Google Material Icons",
licenseName: "Apache-2.0", 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"), url: URL(string: "https://github.com/google/material-design-icons"),
licenseText: apache2Text 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 // MARK: - License bodies
@@ -60,30 +63,4 @@ enum OpenSourceLicenseCatalog {
implied. See the License for the specific language governing implied. See the License for the specific language governing
permissions and limitations under the License. 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.
"""
} }
+62 -191
View File
@@ -11,215 +11,86 @@ struct APISettingsCard: View {
@Environment(\.themePalette) private var palette: ThemePalette @Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig @ObservedObject var config: ProviderConfig
@State private var showKey: Bool = false /// `false`
@State private var testStatus: TestStatus = .idle var showsSurface: Bool = true
private enum TestStatus: Equatable {
case idle
case running
case success
case failure(String)
}
var body: some View { var body: some View {
VStack(spacing: 0) { 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"), title: AppL10n.string("api.baseUrl"),
placeholder: "https://api.openai.com/v1", placeholder: LLMProvider.provider(id: config.providerId).defaultBaseURL,
text: $config.baseURL, text: $config.baseURL,
autocap: false isMonospaced: true
) )
Divider().background(palette.divider) rowDivider
keyField SettingsModelPickerRow(
Divider().background(palette.divider)
field(
title: AppL10n.string("api.model"), title: AppL10n.string("api.model"),
placeholder: "gpt-4o-mini", placeholder: LLMProvider.provider(id: config.providerId).defaultModel,
text: $config.model, model: $config.model,
autocap: false fetchModels: fetchModels
) )
if let url = LLMProvider.provider(id: config.providerId).apiKeyURL { .id(config.providerId)
rowDivider
thinkingRow
rowDivider
SettingsProviderToolsRow(validate: validateConnection)
}
.modifier(SettingsSurfaceCardModifier(enabled: showsSurface))
}
private var rowDivider: some View {
Divider().background(palette.divider) 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 /// Two-line thinking toggle opt-in for slower, higher-quality polish.
// TextField, which manifests as "typing jumps to a website". private var thinkingRow: some View {
Button { HStack(alignment: .center, spacing: Spacing.sm) {
UIApplication.shared.open(url) VStack(alignment: .leading, spacing: Spacing.xxs) {
} label: { Text(AppL10n.string("settings.provider.thinking"))
HStack {
Text("api.getKey")
.font(TypeStyle.body) .font(TypeStyle.body)
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
Spacer() .fixedSize(horizontal: false, vertical: true)
Image(systemName: "arrow.up.right.square") Text(AppL10n.string("settings.provider.thinkingSubtitle"))
.foregroundStyle(palette.textSecondary)
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
Divider().background(palette.divider)
testConnectionRow
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
// 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)
}
// MARK: - Generic field
@ViewBuilder
private func field(
title: String,
placeholder: String,
text: Binding<String>,
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")
.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) .font(TypeStyle.caption2)
.foregroundStyle(testTint) .foregroundStyle(palette.textTertiary)
.lineLimit(3) .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) .settingsListRow()
.padding(.vertical, Spacing.xs)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .center)
} }
private var testButtonLabel: String { private func validateConnection() async throws {
switch testStatus { // Use on-screen config not a fresh AppGroupStore so a just-typed
case .idle: return AppL10n.string("api.test.idle") // key is visible even if Keychain write is still settling.
case .running: return AppL10n.string("api.test.running") let client = LLMClientFactory.make(
case .success: return AppL10n.string("api.test.success") providerId: config.providerId,
case .failure: return AppL10n.string("api.test.failure") baseURL: config.baseURL,
} apiKey: config.apiKey,
} model: config.model,
thinkingEnabled: config.llmThinkingEnabled
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
) )
Task {
do {
_ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.") _ = 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)")
}
} }
private func fetchModels() async throws -> [String] {
try await ProviderModelService.listLLMModels(
providerId: config.providerId,
baseURL: config.baseURL,
apiKey: config.apiKey,
currentModel: config.model
)
} }
} }
+114 -178
View File
@@ -10,195 +10,131 @@ struct ASRSettingsCard: View {
@Environment(\.themePalette) private var palette: ThemePalette @Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig @ObservedObject var config: ProviderConfig
@State private var showKey: Bool = false /// `false`
@State private var testStatus: TestStatus = .idle var showsSurface: Bool = true
private enum TestStatus: Equatable {
case idle
case running
case success
case failure(String)
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
if CloudASRModelCatalog.strategy(for: config.asrProviderId) == .prompt { if config.asrProviderId == "volcengine" {
field( volcengineRows
title: AppL10n.string("api.baseUrl"),
placeholder: "https://api.openai.com/v1",
text: $config.asrBaseURL,
autocap: false
)
Divider().background(palette.divider)
}
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
}
.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 { } else {
SecureField("sk-…", text: $config.asrApiKey) genericRows
} }
rowDivider
SettingsProviderToolsRow(validate: validateConnection)
} }
.keyboardType(.asciiCapable) .modifier(SettingsSurfaceCardModifier(enabled: showsSurface))
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center)
} }
@ViewBuilder @ViewBuilder
private func field( private var genericRows: some View {
title: String, if CloudASRModelCatalog.showsASREndpointField(for: config.asrProviderId) {
placeholder: String, SettingsCredentialRow(
text: Binding<String>, title: AppL10n.string("api.baseUrl"),
autocap: Bool placeholder: LLMProvider.provider(id: config.asrProviderId).defaultBaseURL,
) -> some View { text: $config.asrBaseURL,
VStack(alignment: .leading, spacing: 6) { isMonospaced: true
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)
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.doubleLineMinHeight, alignment: .center)
}
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)
}
}
.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 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 = 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 rowDivider
} catch CloudASRError.noAPIKey { }
testStatus = .failure(AppL10n.string("api.test.missing"))
} catch let error as CloudASRError { SettingsCredentialRow(
testStatus = .failure(error.localizedDescription ?? "\(error)") title: AppL10n.string("api.key"),
} catch { placeholder: "sk-…",
testStatus = .failure((error as? LocalizedError)?.errorDescription ?? "\(error)") 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 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)
} }
} }
} }
private var rowDivider: some View {
Divider().background(palette.divider)
}
private var volcengineFields: VolcengineASRFields {
VolcengineASRFields.parse(
apiKey: config.asrApiKey,
resourceFallback: config.asrModel.isEmpty
? CloudASRModelCatalog.defaultModel(for: "volcengine")
: config.asrModel
)
}
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 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
)
}
} }
@@ -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
@@ -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
@@ -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? { var sfSymbol: String? {
switch self { switch self {
case .dictionary: return "square.stack.3d.down.right.fill" case .dictionary: return "square.stack.3d.down.right.fill"
@@ -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<Bool>,
onConfirm: @escaping () -> Void,
onCancel: @escaping () -> Void
) -> some View {
modifier(CloudSharingAcknowledgmentModifier(
config: config,
isPresented: isPresented,
onConfirm: onConfirm,
onCancel: onCancel
))
}
}
@@ -4,6 +4,8 @@
// Reusable layout pieces for iPad / regular-width surfaces. Styled with the // Reusable layout pieces for iPad / regular-width surfaces. Styled with the
// shared design tokens so the wide Home dashboard can mirror the macOS shell // shared design tokens so the wide Home dashboard can mirror the macOS shell
// without pulling in AppKit-only types from OSGKeyboardMac. // without pulling in AppKit-only types from OSGKeyboardMac.
//
// Home stats (chart + metric tiles) live in Shared as `UsageStatsCluster`.
import SwiftUI import SwiftUI
import OSGKeyboardShared import OSGKeyboardShared
@@ -21,7 +23,7 @@ enum WideLayoutMetrics {
// MARK: - Card container // 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<Content: View>: View { struct WideCard<Content: View>: View {
@Environment(\.themePalette) private var palette @Environment(\.themePalette) private var palette
var padding: CGFloat = Spacing.md var padding: CGFloat = Spacing.md
@@ -39,160 +41,3 @@ struct WideCard<Content: View>: 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
}
}
+1 -28
View File
@@ -11,8 +11,6 @@ struct EnginePickerSection: View {
@Environment(\.themePalette) private var palette: ThemePalette @Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig @ObservedObject var config: ProviderConfig
@State private var showCloudAcknowledgment = false
@State private var pendingEngineSelection: String?
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
@@ -37,18 +35,8 @@ struct EnginePickerSection: View {
RoundedRectangle(cornerRadius: Radius.large, style: .continuous) RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5) .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 { private var localSubtitle: String {
AppL10n.string("settings.engine.local.legacy") AppL10n.string("settings.engine.local.legacy")
@@ -64,11 +52,6 @@ struct EnginePickerSection: View {
let isSelected = config.engineMode == id let isSelected = config.engineMode == id
return Button { return Button {
guard config.engineMode != id else { return } guard config.engineMode != id else { return }
if id == "cloud", !config.hasAcknowledgedCloudSharing {
pendingEngineSelection = id
showCloudAcknowledgment = true
return
}
selectEngine(id) selectEngine(id)
} label: { } label: {
HStack(spacing: Spacing.sm) { HStack(spacing: Spacing.sm) {
@@ -92,8 +75,7 @@ struct EnginePickerSection: View {
.foregroundStyle(palette.accent) .foregroundStyle(palette.accent)
} }
} }
.padding(.horizontal, Spacing.md) .settingsListRow()
.frame(minHeight: SettingsListMetrics.doubleLineMinHeight)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -104,18 +86,9 @@ struct EnginePickerSection: View {
config.engineMode = id config.engineMode = id
if id == "cloud" { if id == "cloud" {
config.modeId = "polish" 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 @ViewBuilder
private func engineMark(assetName: String?, systemIcon: String?, isSelected: Bool) -> some View { private func engineMark(assetName: String?, systemIcon: String?, isSelected: Bool) -> some View {
+28 -13
View File
@@ -85,6 +85,16 @@ struct HomeView: View {
private var phoneBody: some View { private var phoneBody: some View {
GeometryReader { geo in GeometryReader { geo in
let gradientHeight = geo.size.height * 0.30 + geo.safeAreaInsets.top 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) { ZStack(alignment: .top) {
sessionHeaderGradient(height: gradientHeight) sessionHeaderGradient(height: gradientHeight)
@@ -92,23 +102,25 @@ struct HomeView: View {
.allowsHitTesting(false) .allowsHitTesting(false)
VStack(spacing: 0) { VStack(spacing: 0) {
logoHeader logoHeader(compact: isCompact)
.padding(.top, Spacing.xxxl) .padding(.top, logoTopPadding)
.padding(.bottom, Spacing.xxl) .padding(.bottom, logoBottomPadding)
if showsFlowSessionExtras { if showsFlowSessionExtras {
flowSessionExtras flowSessionExtras
.padding(.horizontal, Spacing.lg) .padding(.horizontal, Spacing.lg)
.padding(.bottom, Spacing.lg) .padding(.bottom, extrasBottomPadding)
} }
HomeStatsCard() HomeUsageStatsSection(layout: .stacked, compact: isCompact)
.padding(.horizontal, Spacing.lg) .padding(.horizontal, Spacing.lg)
.padding(.bottom, Spacing.md) .padding(.bottom, Spacing.md)
previewField //
previewField(minHeight: previewMinHeight)
.padding(.horizontal, Spacing.lg) .padding(.horizontal, Spacing.lg)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.layoutPriority(-1)
HStack(spacing: Spacing.sm) { HStack(spacing: Spacing.sm) {
engineStatusLine engineStatusLine
@@ -116,7 +128,7 @@ struct HomeView: View {
} }
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
.padding(.horizontal, Spacing.lg) .padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.xl) .padding(.top, statusTopPadding)
.padding(.bottom, Spacing.sm) .padding(.bottom, Spacing.sm)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
@@ -138,7 +150,7 @@ struct HomeView: View {
VStack(alignment: .leading, spacing: Spacing.lg) { VStack(alignment: .leading, spacing: Spacing.lg) {
wideHeroHeader wideHeroHeader
WideHomeStatsCluster() HomeUsageStatsSection(layout: .split)
if showsFlowSessionExtras { if showsFlowSessionExtras {
flowSessionExtras flowSessionExtras
@@ -235,12 +247,15 @@ struct HomeView: View {
// MARK: - Header // MARK: - Header
private var logoHeader: some View { // logo 144:41
VStack(spacing: Spacing.xxl) { 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") Image("osglogo")
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: 144, height: 41) .frame(width: logoWidth, height: logoHeight)
.accessibilityHidden(true) .accessibilityHidden(true)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -444,9 +459,9 @@ struct HomeView: View {
// MARK: - Preview field // MARK: - Preview field
private var previewField: some View { private func previewField(minHeight: CGFloat) -> some View {
previewFieldContent previewFieldContent
.frame(maxWidth: .infinity, minHeight: 180, maxHeight: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, minHeight: minHeight, maxHeight: .infinity, alignment: .topLeading)
.padding(Spacing.md) .padding(Spacing.md)
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay( .overlay(
@@ -25,8 +25,10 @@ struct LocalModelsGroup: View {
speechRow speechRow
Divider().background(palette.divider) Divider().background(palette.divider)
polishRow polishRow
#if DEBUG
Divider().background(palette.divider) Divider().background(palette.divider)
customLanguageModelDiagnosticRow customLanguageModelDiagnosticRow
#endif
} }
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay( .overlay(
@@ -45,8 +47,7 @@ struct LocalModelsGroup: View {
Spacer(minLength: Spacing.xs) Spacer(minLength: Spacing.xs)
engineBadge("settings.localModels.speechEngine") engineBadge("settings.localModels.speechEngine")
} }
.padding(.horizontal, Spacing.md) .settingsListRow()
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
} }
// MARK: Polish row // MARK: Polish row
@@ -61,12 +62,12 @@ struct LocalModelsGroup: View {
Spacer(minLength: Spacing.xs) Spacer(minLength: Spacing.xs)
engineBadge("settings.localModels.polishEngine") engineBadge("settings.localModels.polishEngine")
} }
.padding(.horizontal, Spacing.md) .settingsListRow()
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
} }
// MARK: Custom language model diagnostic row // MARK: Custom language model diagnostic row (DEBUG builds only)
#if DEBUG
private var customLanguageModelDiagnosticRow: some View { private var customLanguageModelDiagnosticRow: some View {
Toggle(isOn: $config.localASRCustomLanguageModelEnabled) { Toggle(isOn: $config.localASRCustomLanguageModelEnabled) {
VStack(alignment: .leading, spacing: Spacing.xxs) { VStack(alignment: .leading, spacing: Spacing.xxs) {
@@ -79,10 +80,9 @@ struct LocalModelsGroup: View {
} }
} }
.tint(palette.accent) .tint(palette.accent)
.padding(.horizontal, Spacing.md) .settingsListRow()
.padding(.vertical, Spacing.sm)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
} }
#endif
// MARK: Helpers // MARK: Helpers
+66 -33
View File
@@ -1,12 +1,13 @@
// OnboardingView.swift // OnboardingView.swift
// OSGKeyboard · Main App // OSGKeyboard · Main App
// //
// Five-step onboarding: // Six-step onboarding:
// 1) Welcome // 1) Welcome
// 2) Microphone permission // 2) Microphone permission
// 3) Speech recognition permission // 3) Speech recognition permission
// 4) Enable keyboard + Allow Full Access // 4) Enable keyboard + Allow Full Access
// 5) Engine / API setup // 5) Engine / ASR setup
// 6) Polish LLM setup
import SwiftUI import SwiftUI
import OSGKeyboardShared import OSGKeyboardShared
@@ -18,8 +19,9 @@ private enum OnboardingPage: Int, CaseIterable {
case speech case speech
case keyboard case keyboard
case api case api
case polish
static let count = 5 static let count = 6
} }
struct OnboardingView: View { struct OnboardingView: View {
@@ -61,6 +63,8 @@ struct OnboardingView: View {
EnableKeyboardPage() EnableKeyboardPage()
case .api: case .api:
APISetupPage(config: config) APISetupPage(config: config)
case .polish:
PolishSetupPage(config: config)
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .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 { private var canAdvance: Bool {
switch currentPage { switch currentPage {
case .welcome, .keyboard: case .welcome, .keyboard, .api:
return !isLastPage || onboardingCompleteReady
case .api:
return !isLastPage || onboardingCompleteReady return !isLastPage || onboardingCompleteReady
case .polish:
return onboardingCompleteReady
case .microphone: case .microphone:
return micStatus != .undetermined return micStatus != .undetermined
case .speech: case .speech:
@@ -250,9 +254,8 @@ struct OnboardingView: View {
} }
private var onboardingCompleteReady: Bool { private var onboardingCompleteReady: Bool {
// v0.2.0: the local engine uses iOS `SpeechAnalyzer`, which is // Local engine: built-in polish satisfies `isPolishConfigured`.
// always available no model download gate. The cloud engine // Cloud engine: ASR (step 5) + polish LLM (step 6) must both be ready.
// still requires base URL + API key + model.
if config.isLocalEngine { return true } if config.isLocalEngine { return true }
return config.isConfigured return config.isConfigured
} }
@@ -364,7 +367,6 @@ private struct OnboardingTitleBlock: View {
// MARK: - Welcome // MARK: - Welcome
private struct WelcomePage: View { private struct WelcomePage: View {
@Environment(\.themePalette) private var palette: ThemePalette
@State private var logoAppeared = false @State private var logoAppeared = false
var body: some View { var body: some View {
@@ -386,26 +388,13 @@ private struct WelcomePage: View {
} }
OnboardingTitleBlock( OnboardingTitleBlock(
title: "onboarding.welcome.tagline", title: "onboarding.welcome.tagline"
subtitle: "onboarding.welcome.subtitle",
secondarySubtitle: "onboarding.welcome.subtitle2"
) )
.opacity(logoAppeared ? 1 : 0) .opacity(logoAppeared ? 1 : 0)
.offset(y: logoAppeared ? 0 : 10) .offset(y: logoAppeared ? 0 : 10)
.padding(.top, OnboardingLayoutMetrics.heroTextGap) .padding(.top, OnboardingLayoutMetrics.heroTextGap)
.animation(.spring(response: 0.8, dampingFraction: 0.85).delay(0.12), value: logoAppeared) .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() Spacer()
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -708,7 +697,7 @@ private struct EnableKeyboardPage: View {
} }
} }
// MARK: - API setup // MARK: - Engine / ASR setup
private struct APISetupPage: View { private struct APISetupPage: View {
@Environment(\.themePalette) private var palette: ThemePalette @Environment(\.themePalette) private var palette: ThemePalette
@@ -728,16 +717,60 @@ private struct APISetupPage: View {
.padding(.horizontal, Spacing.lg) .padding(.horizontal, Spacing.lg)
if config.engineMode == "cloud" { if config.engineMode == "cloud" {
ProviderPickerSection(config: config) VStack(spacing: 0) {
.padding(.horizontal, Spacing.lg) ProviderPickerSection(config: config, role: .asr, showsSurface: false)
APISettingsCard(config: config) Divider().background(palette.divider)
ASRSettingsCard(config: config, showsSurface: false)
}
.modifier(SettingsSurfaceCardModifier(enabled: true))
.padding(.horizontal, Spacing.lg) .padding(.horizontal, Spacing.lg)
} else { } else {
// Local engine: built-in ASR + built-in polish (no API card). Text("onboarding.api.localModels.hint")
LocalModelsGroup(config: config) .font(TypeStyle.body)
.padding(.horizontal, Spacing.lg) .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) .padding(.bottom, Spacing.xxxl)
} }
@@ -64,8 +64,7 @@ struct OpenSourceLicensesView: View {
.font(.system(size: 11, weight: .semibold)) .font(.system(size: 11, weight: .semibold))
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textTertiary)
} }
.padding(.horizontal, Spacing.md) .settingsListRow()
.padding(.vertical, 10)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
} }
@@ -30,8 +30,7 @@ struct PersonalDictionaryEntrySheet: View {
.focused($termFocused) .focused($termFocused)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
.padding(.horizontal, Spacing.md) .settingsListRow()
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay( .overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous) RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
@@ -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<Bool> {
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")
}
}
}
@@ -170,8 +170,7 @@ struct PersonalDictionaryView: View {
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textTertiary)
} }
.padding(.horizontal, Spacing.md) .settingsListRow()
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
+41 -30
View File
@@ -9,33 +9,47 @@ struct ProviderPickerSection: View {
@ObservedObject var config: ProviderConfig @ObservedObject var config: ProviderConfig
var role: CloudProviderRole = .polish var role: CloudProviderRole = .polish
/// `false`
var showsSurface: Bool = true
private var selectedProviderId: String { private var selectedProviderId: String {
role == .asr ? config.asrProviderId : config.providerId role == .asr ? config.asrProviderId : config.providerId
} }
var body: some View { private var visiblePresets: [LLMProvider] {
let visiblePresets = role == .asr role == .asr
? LLMProvider.asrSelectablePresets ? LLMProvider.asrSelectablePresets
: LLMProvider.userSelectablePresets : LLMProvider.userSelectablePresets
VStack(spacing: 0) { }
ForEach(Array(visiblePresets.enumerated()), id: \.element.id) { index, provider in
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 { Button {
select(provider) select(provider)
} label: { } label: {
row(provider, selected: provider.id == selectedProviderId) let name = ProviderDisplayName.name(for: provider.id)
if provider.id == selectedProviderId {
Label(name, systemImage: "checkmark")
} else {
Text(name)
}
}
}
} label: {
providerChip
} }
.buttonStyle(.plain) .buttonStyle(.plain)
if index < visiblePresets.count - 1 {
Divider().background(palette.divider)
} }
} .modifier(SettingsSurfaceCardModifier(enabled: showsSurface))
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
} }
private func select(_ provider: LLMProvider) { private func select(_ provider: LLMProvider) {
@@ -49,48 +63,45 @@ struct ProviderPickerSection: View {
} }
} }
@ViewBuilder /// logo + +
private func row(_ provider: LLMProvider, selected: Bool) -> some View { private var providerChip: some View {
HStack(spacing: Spacing.sm) { 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) .font(TypeStyle.body)
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
if provider.supportsPersonalDictionaryCloudASR { if selectedProvider.supportsPersonalDictionaryCloudASR {
personalDictionaryBadge personalDictionaryBadge
} }
Spacer(minLength: Spacing.xs) Spacer(minLength: Spacing.xs)
if selected { Image(systemName: "chevron.up.chevron.down")
Image(systemName: "checkmark.circle.fill") .font(.system(size: 12, weight: .semibold))
.font(.system(size: 17, weight: .medium)) .foregroundStyle(palette.textTertiary)
.foregroundStyle(palette.accent)
} }
} .frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
@ViewBuilder @ViewBuilder
private func providerMark(_ provider: LLMProvider, selected: Bool) -> some View { private func providerMark(_ provider: LLMProvider) -> some View {
ZStack { ZStack {
Circle() Circle()
.fill(selected ? palette.accentMuted : palette.surfaceElevated) .fill(palette.accentMuted)
.frame(width: 32, height: 32) .frame(width: 32, height: 32)
if let asset = ProviderLogo.assetName(for: provider.id) { if let asset = ProviderLogo.assetName(for: provider.id) {
Image(asset) Image(asset)
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: 18, height: 18) .frame(width: 18, height: 18)
.foregroundStyle(selected ? palette.accent : palette.textPrimary) .foregroundStyle(palette.accent)
} else { } else {
Text(String(provider.name.prefix(1))) Text(String(provider.name.prefix(1)))
.font(TypeStyle.caption) .font(TypeStyle.caption)
.foregroundStyle(selected ? palette.accent : palette.textSecondary) .foregroundStyle(palette.accent)
} }
} }
} }
@@ -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
}
}
}
+57 -7
View File
@@ -14,6 +14,9 @@ struct SettingsICloudSyncRow: View {
@State private var syncErrorMessage: String? @State private var syncErrorMessage: String?
@State private var isApplyingToggle = false @State private var isApplyingToggle = false
@State private var isSyncingNow = 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() private let store = AppGroupStore()
@@ -38,13 +41,21 @@ struct SettingsICloudSyncRow: View {
syncNow() syncNow()
} label: { } label: {
HStack(spacing: Spacing.xs) { HStack(spacing: Spacing.xs) {
Text(syncButtonTitleKey)
.font(TypeStyle.caption)
Spacer(minLength: 0)
if isSyncingNow { if isSyncingNow {
ProgressView() 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) .buttonStyle(.plain)
.foregroundStyle(palette.accent) .foregroundStyle(palette.accent)
@@ -60,15 +71,18 @@ struct SettingsICloudSyncRow: View {
.padding(.top, Spacing.xxs) .padding(.top, Spacing.xxs)
} }
} }
.padding(.horizontal, Spacing.md) .settingsListRow(alignment: .leading)
.padding(.vertical, Spacing.sm)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .leading)
.onAppear { reloadFromStore() } .onAppear { reloadFromStore() }
.onReceive( .onReceive(
NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud) NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)
) { _ in ) { _ in
reloadFromStore() reloadFromStore()
} }
.onReceive(
NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)
) { _ in
reloadFromStore()
}
} }
private var toggleBinding: Binding<Bool> { private var toggleBinding: Binding<Bool> {
@@ -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() { private func reloadFromStore() {
isEnabled = store.settingsICloudSyncEnabled isEnabled = store.settingsICloudSyncEnabled
} }
@@ -95,6 +119,15 @@ struct SettingsICloudSyncRow: View {
Task { Task {
do { do {
try await CloudSyncContext.shared.settingsSyncService.enableSync() 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() reloadFromStore()
} catch let error as SettingsCloudSyncError { } catch let error as SettingsCloudSyncError {
isEnabled = false isEnabled = false
@@ -114,15 +147,23 @@ struct SettingsICloudSyncRow: View {
} }
private func syncNow() { private func syncNow() {
guard !isSyncingNow else { return }
isSyncingNow = true isSyncingNow = true
showSyncedConfirmation = false
syncErrorMessage = nil syncErrorMessage = nil
Task { Task {
do { do {
try await CloudSyncContext.shared.syncNow() 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 { } catch {
isSyncingNow = false
syncErrorMessage = AppL10n.string("settings.appSettings.iCloudSync.error.generic") 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") 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")
}
}
} }
@@ -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<Content: View>: 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
}
}
+28 -46
View File
@@ -43,15 +43,13 @@ struct SettingsView: View {
dictionaryAndPolishSection dictionaryAndPolishSection
flowSessionSection flowSessionSection
engineSection engineSection
polishProviderSection
polishApiSection
if config.engineMode == "cloud" { if config.engineMode == "cloud" {
asrProviderSection asrSettingsSection
asrApiSection
} }
if config.engineMode == "local" { if config.engineMode == "local" {
localEngineSettingsSection localEngineSettingsSection
} }
polishSettingsSection
if presentation == .tab { if presentation == .tab {
footerLinks footerLinks
} }
@@ -115,9 +113,7 @@ struct SettingsView: View {
} }
} }
.tint(palette.accent) .tint(palette.accent)
.padding(.horizontal, Spacing.md) .settingsListRow()
.padding(.vertical, Spacing.sm)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
Divider().background(palette.divider) Divider().background(palette.divider)
@@ -222,10 +218,6 @@ struct SettingsView: View {
Divider().background(palette.divider) Divider().background(palette.divider)
TranslationPickerRow(config: config, isVisible: config.isTranslationRowVisible) TranslationPickerRow(config: config, isVisible: config.isTranslationRowVisible)
Divider().background(palette.divider)
PersonalDictionaryICloudSyncRow()
} }
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay( .overlay(
@@ -248,40 +240,36 @@ struct SettingsView: View {
} }
} }
private var polishProviderSection: some View { private var polishSettingsSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.polishProvider.title") sectionHeader("settings.polishProvider.title")
Text("settings.polishProvider.subtitle") cloudProviderSettingsCard {
.font(TypeStyle.caption2) ProviderPickerSection(config: config, role: .polish, showsSurface: false)
.foregroundStyle(palette.textTertiary) Divider().background(palette.divider)
.frame(maxWidth: .infinity, alignment: .leading) APISettingsCard(config: config, showsSurface: false)
ProviderPickerSection(config: config, role: .polish) }
} }
} }
private var asrProviderSection: some View { private var asrSettingsSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.asrProvider.title") sectionHeader("settings.asrProvider.title")
Text("settings.asrProvider.subtitle") cloudProviderSettingsCard {
.font(TypeStyle.caption2) ProviderPickerSection(config: config, role: .asr, showsSurface: false)
.foregroundStyle(palette.textTertiary) Divider().background(palette.divider)
.frame(maxWidth: .infinity, alignment: .leading) ASRSettingsCard(config: config, showsSurface: false)
ProviderPickerSection(config: config, role: .asr) }
} }
} }
private var polishApiSection: some View { @ViewBuilder
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { private func cloudProviderSettingsCard<Content: View>(
sectionHeader("settings.polishApi.title") @ViewBuilder content: () -> Content
APISettingsCard(config: config) ) -> some View {
} VStack(spacing: 0) {
} content()
private var asrApiSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.asrApi.title")
ASRSettingsCard(config: config)
} }
.modifier(SettingsSurfaceCardModifier(enabled: true))
} }
// MARK: - Language helpers // MARK: - Language helpers
@@ -352,8 +340,7 @@ struct SettingsView: View {
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
} }
.tint(palette.accent) .tint(palette.accent)
.padding(.horizontal, Spacing.md) .settingsListRow()
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
} }
// MARK: - Footer links (tab settings only) // MARK: - Footer links (tab settings only)
@@ -375,8 +362,7 @@ struct SettingsView: View {
.font(.system(size: 14, weight: .semibold)) .font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textTertiary)
} }
.padding(.horizontal, Spacing.md) .settingsListRow()
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -432,8 +418,7 @@ struct SettingsView: View {
MaterialIcon(name: .openInNew, size: 18) MaterialIcon(name: .openInNew, size: 18)
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textTertiary)
} }
.padding(.horizontal, Spacing.md) .settingsListRow()
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -453,8 +438,7 @@ struct SettingsView: View {
.font(.system(size: 14, weight: .semibold)) .font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textTertiary)
} }
.padding(.horizontal, Spacing.md) .settingsListRow()
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
@@ -596,8 +580,7 @@ private struct PickerRow: View {
} }
} }
} }
.padding(.horizontal, Spacing.md) .settingsListRow()
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
} }
private var currentLabel: String { private var currentLabel: String {
@@ -654,8 +637,7 @@ private struct LocalePickerRow: View {
} }
} }
} }
.padding(.horizontal, Spacing.md) .settingsListRow()
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
} }
private func label(for localeId: String) -> String { private func label(for localeId: String) -> String {
+1 -4
View File
@@ -69,13 +69,10 @@ struct TranslationPickerRow: View {
} }
} }
} }
.padding(.horizontal, Spacing.md) .settingsListRow()
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
} }
} }
// MARK: - Selection plumbing
/// Currently selected id the picker always reads /// Currently selected id the picker always reads
/// `translationTargetLocaleId` directly (the previous /// `translationTargetLocaleId` directly (the previous
/// `translationEnabled` boolean is now derived from it). /// `translationEnabled` boolean is now derived from it).
+37 -21
View File
@@ -4,9 +4,7 @@
/* Onboarding */ /* Onboarding */
"onboarding.progress" = "Step %1$d of %2$d"; "onboarding.progress" = "Step %1$d of %2$d";
"onboarding.welcome.tagline" = "Speak instead of type"; "onboarding.welcome.tagline" = "Speak it. Its typed.";
"onboarding.welcome.subtitle" = "Open-source voice keyboard";
"onboarding.welcome.subtitle2" = "We never read or upload anything you type.";
"onboarding.permission.preface" = "iOS will ask for two permissions — please allow both."; "onboarding.permission.preface" = "iOS will ask for two permissions — please allow both.";
"onboarding.permission.mic.title" = "Microphone"; "onboarding.permission.mic.title" = "Microphone";
"onboarding.permission.mic.body" = "Records your voice."; "onboarding.permission.mic.body" = "Records your voice.";
@@ -29,7 +27,10 @@
"onboarding.enable.step3.suffix" = "and select OSGKeyboard"; "onboarding.enable.step3.suffix" = "and select OSGKeyboard";
"onboarding.enable.openSettings" = "Open Settings"; "onboarding.enable.openSettings" = "Open Settings";
"onboarding.api.title" = "Choose Engine"; "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"; "settings.onboarding.replay" = "Restart permission setup";
/* Common navigation */ /* Common navigation */
@@ -44,6 +45,7 @@
"common.delete" = "Delete"; "common.delete" = "Delete";
"common.space" = "Space"; "common.space" = "Space";
"common.newline" = "Return"; "common.newline" = "Return";
"common.send" = "Send";
/* Privacy footnote (onboarding engine page) */ /* Privacy footnote (onboarding engine page) */
"privacy.audio.title" = "On-device transcription"; "privacy.audio.title" = "On-device transcription";
@@ -92,13 +94,13 @@
"settings.reset.title" = "Reset all settings?"; "settings.reset.title" = "Reset all settings?";
"settings.reset.message" = "API key, model, base URL, voice history, and cloud consent will be cleared."; "settings.reset.message" = "API key, model, base URL, voice history, and cloud consent will be cleared.";
"settings.reset.confirm" = "Reset all settings"; "settings.reset.confirm" = "Reset all settings";
"settings.engine.title" = "Recognition method"; "settings.engine.title" = "Speech transcription method";
"settings.engine.subtitle" = "Local: on-device ASR only. Cloud: ASR + polish via your API."; "settings.engine.subtitle" = "Local: built-in system transcription. Cloud: ASR transcription with optional LLM polish.";
"settings.engine.local.title" = "On-device recognition"; "settings.engine.local.title" = "On-device transcription";
"settings.engine.local.ios26" = "Always on-device, no network."; "settings.engine.local.ios26" = "Always on-device, no network.";
"settings.engine.local.legacy" = "On-device ASR. Transcription only, no polish."; "settings.engine.local.legacy" = "Transcribe with the built-in system speech service";
"settings.engine.cloud.title" = "Cloud recognition & polish"; "settings.engine.cloud.title" = "Cloud transcription";
"settings.engine.cloud.subtitle" = "Cloud ASR and polish LLM are configured separately. Audio goes to your ASR provider."; "settings.engine.cloud.subtitle" = "Cloud ASR transcription, with optional LLM polish";
"settings.engine.cloud.badge" = "Cloud engine"; "settings.engine.cloud.badge" = "Cloud engine";
"settings.provider.title" = "Provider"; "settings.provider.title" = "Provider";
"settings.provider.personalDictionaryBadge" = "Personal dictionary"; "settings.provider.personalDictionaryBadge" = "Personal dictionary";
@@ -111,6 +113,26 @@
"settings.asrApi.title" = "ASR API"; "settings.asrApi.title" = "ASR API";
"settings.asr.model" = "ASR model"; "settings.asr.model" = "ASR model";
"settings.asr.testConnection" = "Test ASR"; "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.openai" = "OpenAI";
"provider.deepseek" = "DeepSeek"; "provider.deepseek" = "DeepSeek";
"provider.qwen" = "Qwen (DashScope)"; "provider.qwen" = "Qwen (DashScope)";
@@ -313,16 +335,8 @@
"home.flow.startShort" = "Start"; "home.flow.startShort" = "Start";
"home.preview.label" = "Try typing"; "home.preview.label" = "Try typing";
"home.preview.placeholder" = "Tap to type and test…"; "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" = "Voice dictation, anywhere.";
"home.wide.tagline.subtitle" = "Switch to any app and tap the keyboard mic to dictate."; "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.cloud" = "Cloud";
"home.wide.mode.local" = "On-device"; "home.wide.mode.local" = "On-device";
"home.wide.devices" = "iPhone & iPad"; "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.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.personalDictionary.iCloudSync.error.generic" = "Could not sync your dictionary with iCloud. Try again later.";
"settings.iCloudSync.title" = "iCloud Sync"; "settings.iCloudSync.title" = "Cross-Device iCloud Sync";
"settings.appSettings.iCloudSync.title" = "iCloud Sync"; "settings.appSettings.iCloudSync.title" = "Cross-Device 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.appSettings.iCloudSync.subtitle" = "Sync settings, history, and API keys across devices via iCloud.";
"settings.appSettings.iCloudSync.syncNow" = "Sync Now"; "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."; "settings.appSettings.iCloudSync.error.generic" = "Could not sync settings with iCloud. Try again later.";
/* v0.3.0: Polish intensity */ /* v0.3.0: Polish intensity */
+38 -22
View File
@@ -4,9 +4,7 @@
/* Onboarding */ /* Onboarding */
"onboarding.progress" = "第 %1$d / %2$d 步"; "onboarding.progress" = "第 %1$d / %2$d 步";
"onboarding.welcome.tagline" = "能说就不打字"; "onboarding.welcome.tagline" = "开口即文字。";
"onboarding.welcome.subtitle" = "开源语音输入法";
"onboarding.welcome.subtitle2" = "不读取、不上传任何输入内容。";
"onboarding.permission.preface" = "系统会弹出两次授权,请「允许」权限申请。"; "onboarding.permission.preface" = "系统会弹出两次授权,请「允许」权限申请。";
"onboarding.permission.mic.title" = "麦克风"; "onboarding.permission.mic.title" = "麦克风";
"onboarding.permission.mic.body" = "用于录制语音。"; "onboarding.permission.mic.body" = "用于录制语音。";
@@ -29,7 +27,10 @@
"onboarding.enable.step3.suffix" = ",选中 OSGKeyboard"; "onboarding.enable.step3.suffix" = ",选中 OSGKeyboard";
"onboarding.enable.openSettings" = "去设置"; "onboarding.enable.openSettings" = "去设置";
"onboarding.api.title" = "选择语音转文字 AI 引擎"; "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" = "重新开始权限引导"; "settings.onboarding.replay" = "重新开始权限引导";
/* Common navigation */ /* Common navigation */
@@ -43,7 +44,8 @@
"common.clear" = "清空"; "common.clear" = "清空";
"common.delete" = "删除"; "common.delete" = "删除";
"common.space" = "空格"; "common.space" = "空格";
"common.newline" = "换行"; "common.newline" = "回车";
"common.send" = "发送";
/* Privacy footnote (onboarding engine page) */ /* Privacy footnote (onboarding engine page) */
"privacy.audio.title" = "支持本地转写"; "privacy.audio.title" = "支持本地转写";
@@ -92,13 +94,13 @@
"settings.reset.title" = "重置所有设置?"; "settings.reset.title" = "重置所有设置?";
"settings.reset.message" = "将清空 API Key、模型、接口地址、语音历史及云端确认状态。"; "settings.reset.message" = "将清空 API Key、模型、接口地址、语音历史及云端确认状态。";
"settings.reset.confirm" = "重置所有设置"; "settings.reset.confirm" = "重置所有设置";
"settings.engine.title" = "识别方式"; "settings.engine.title" = "语音转写方式";
"settings.engine.subtitle" = "本地:端侧识别,仅转录;云端:通过 API 润色。"; "settings.engine.subtitle" = "本地:系统内置转写;云端:ASR 转写,可用 LLM 润色。";
"settings.engine.local.title" = "本地识别"; "settings.engine.local.title" = "本地转写";
"settings.engine.local.ios26" = "全程在手机本地,不用联网"; "settings.engine.local.ios26" = "全程在手机本地,不用联网";
"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。"; "settings.engine.local.legacy" = "通过系统内置服务语音转写";
"settings.engine.cloud.title" = "云端识别与润色"; "settings.engine.cloud.title" = "云端转写";
"settings.engine.cloud.subtitle" = "云端 ASR 与润色 LLM 分开配置;音频发送至转写服务商。"; "settings.engine.cloud.subtitle" = "通过云端 ASR 转写,可用 LLM 润色";
"settings.engine.cloud.badge" = "云端引擎"; "settings.engine.cloud.badge" = "云端引擎";
"settings.provider.title" = "云端引擎"; "settings.provider.title" = "云端引擎";
"settings.provider.personalDictionaryBadge" = "个性词库"; "settings.provider.personalDictionaryBadge" = "个性词库";
@@ -111,6 +113,26 @@
"settings.asrApi.title" = "转写接口"; "settings.asrApi.title" = "转写接口";
"settings.asr.model" = "ASR 模型"; "settings.asr.model" = "ASR 模型";
"settings.asr.testConnection" = "测试转写"; "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.openai" = "OpenAI";
"provider.deepseek" = "DeepSeek"; "provider.deepseek" = "DeepSeek";
"provider.qwen" = "通义千问"; "provider.qwen" = "通义千问";
@@ -312,16 +334,8 @@
"home.flow.startShort" = "开启"; "home.flow.startShort" = "开启";
"home.preview.label" = "输入测试"; "home.preview.label" = "输入测试";
"home.preview.placeholder" = "点这里试试键盘"; "home.preview.placeholder" = "点这里试试键盘";
"home.stats.dictationDuration" = "听写时长";
"home.stats.dictationCharacters" = "听写字数";
"home.stats.translationCharacters" = "翻译字数";
"home.stats.dictionaryEntries" = "个性词库";
"home.wide.tagline" = "随处语音听写"; "home.wide.tagline" = "随处语音听写";
"home.wide.tagline.subtitle" = "切换到任意 App,点键盘麦克风即可听写。"; "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.cloud" = "云端";
"home.wide.mode.local" = "本机"; "home.wide.mode.local" = "本机";
"home.wide.devices" = "iPhone 与 iPad"; "home.wide.devices" = "iPhone 与 iPad";
@@ -386,10 +400,12 @@
"settings.personalDictionary.iCloudSync.error.tooLarge" = "词库过大,无法通过 iCloud 同步。请删除部分词条后重试。"; "settings.personalDictionary.iCloudSync.error.tooLarge" = "词库过大,无法通过 iCloud 同步。请删除部分词条后重试。";
"settings.personalDictionary.iCloudSync.error.generic" = "无法与 iCloud 同步词库,请稍后重试。"; "settings.personalDictionary.iCloudSync.error.generic" = "无法与 iCloud 同步词库,请稍后重试。";
"settings.iCloudSync.title" = "iCloud 同步"; "settings.iCloudSync.title" = "跨设备iCloud 同步";
"settings.appSettings.iCloudSync.title" = "iCloud 同步"; "settings.appSettings.iCloudSync.title" = "跨设备iCloud 同步";
"settings.appSettings.iCloudSync.subtitle" = "通过 iCloud 在多台设备同步设置、使用统计、语音历史与 API 密钥。API 密钥经私人 iCloud 钥匙串同步。"; "settings.appSettings.iCloudSync.subtitle" = "通过 iCloud 设备同步设置、历史记录、API Key";
"settings.appSettings.iCloudSync.syncNow" = "立即同步"; "settings.appSettings.iCloudSync.syncNow" = "立即同步";
"settings.appSettings.iCloudSync.syncing" = "同步中…";
"settings.appSettings.iCloudSync.synced" = "已同步";
"settings.appSettings.iCloudSync.error.generic" = "无法与 iCloud 同步设置,请稍后重试。"; "settings.appSettings.iCloudSync.error.generic" = "无法与 iCloud 同步设置,请稍后重试。";
/* v0.3.0: 润色强度 */ /* v0.3.0: 润色强度 */
@@ -105,6 +105,12 @@ public final class KeyboardViewController: UIInputViewController {
super.viewDidAppear(animated) super.viewDidAppear(animated)
disableSystemGestureDelays() disableSystemGestureDelays()
keyboardHeightConstraint?.constant = targetKeyboardHeight keyboardHeightConstraint?.constant = targetKeyboardHeight
refreshReturnKeyRole()
}
public override func textDidChange(_ textInput: UITextInput?) {
super.textDidChange(textInput)
refreshReturnKeyRole()
} }
public override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge { 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 // MARK: - System keyboard chrome
private func configureDictationBehavior() { private func configureDictationBehavior() {
@@ -48,8 +48,21 @@ final class KeyboardFlowCoordinator {
/// `adoptHostBusyStateIfNeeded` from re-entering `.processing` after a /// `adoptHostBusyStateIfNeeded` from re-entering `.processing` after a
/// stale App Group snapshot still says `reason=processing`. /// stale App Group snapshot still says `reason=processing`.
private var lastConsumedUtteranceId: UUID? 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 currentCommandSeq: Int64 = 0
private var lastAvailabilityTraceSignature = "" 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( init(
state: KeyboardState, state: KeyboardState,
@@ -120,6 +133,8 @@ final class KeyboardFlowCoordinator {
recomputeMicVoiceAvailability() recomputeMicVoiceAvailability()
startHostReadyWaitIfNeeded() 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* // Only surface "session ended" when the session contract *genuinely*
// dropped (expired / cleared). A transient host-ready flap engine // 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. // forever miss the real delivery and leaves the mic white forever.
guard let busyId = snapshot.busyUtteranceId else { return } guard let busyId = snapshot.busyUtteranceId else { return }
guard busyId != lastConsumedUtteranceId else { return } guard busyId != lastConsumedUtteranceId else { return }
guard busyId != lastStoppedUtteranceId else { return }
activeSessionId = sessionId activeSessionId = sessionId
currentUtteranceId = busyId currentUtteranceId = busyId
isPendingFlowStart = false isPendingFlowStart = false
@@ -268,6 +284,7 @@ final class KeyboardFlowCoordinator {
state.lastTranscript = "" state.lastTranscript = ""
stopFlowWatchdog() stopFlowWatchdog()
currentUtteranceId = nil currentUtteranceId = nil
lastStoppedUtteranceId = nil
traceState( traceState(
"stickyProcessing.cleared", "stickyProcessing.cleared",
extra: hostReady ? "hostReady=1" : "hostReady=0" extra: hostReady ? "hostReady=1" : "hostReady=0"
@@ -280,16 +297,24 @@ final class KeyboardFlowCoordinator {
guard !isPendingFlowStart else { return } guard !isPendingFlowStart else { return }
guard FlowSessionBridge.isSessionActive() else { guard FlowSessionBridge.isSessionActive() else {
stopHostReadyWait() stopHostReadyWait()
if recordWhenHostReady {
// Session gone while waiting escalate to a real cold start.
let shouldRecord = recordWhenHostReady
recordWhenHostReady = false
beginFlowStart(recordAfterHandoff: shouldRecord)
}
return return
} }
// Host busy waiting for ready. Do not spin the ready-wait poll. // Host busy waiting for ready. Do not spin the ready-wait poll.
if let reason = FlowSessionBridge.readySnapshot()?.reason, if let reason = FlowSessionBridge.readySnapshot()?.reason,
reason == .recording || reason == .processing { reason == .recording || reason == .processing {
stopHostReadyWait() stopHostReadyWait()
recordWhenHostReady = false
return return
} }
guard !FlowSessionBridge.isHostReady() else { if FlowSessionBridge.isHostReady() {
stopHostReadyWait() stopHostReadyWait()
finishHostReadyWaitIfNeeded()
return return
} }
@@ -300,15 +325,49 @@ final class KeyboardFlowCoordinator {
guard let self, !Task.isCancelled else { return } guard let self, !Task.isCancelled else { return }
FlowSessionBridge.reloadFromDisk() FlowSessionBridge.reloadFromDisk()
self.recomputeMicVoiceAvailability() self.recomputeMicVoiceAvailability()
if self.state.micVoiceAvailability.isReady if self.state.micVoiceAvailability.isReady {
|| self.state.micVoiceAvailability == .recording self.finishHostReadyWaitIfNeeded()
return
}
if self.state.micVoiceAvailability == .recording
|| self.state.micVoiceAvailability == .processing { || 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 return
} }
try? await Task.sleep(nanoseconds: 150_000_000) 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() { private func stopHostReadyWait() {
hostReadyWaitTask?.cancel() hostReadyWaitTask?.cancel()
@@ -338,9 +397,6 @@ final class KeyboardFlowCoordinator {
recomputeMicVoiceAvailability() recomputeMicVoiceAvailability()
switch state.micVoiceAvailability { switch state.micVoiceAvailability {
case .ready:
detectAndStoreAppContext()
startFlowRecording()
case .unavailable(.missingAPIKey): case .unavailable(.missingAPIKey):
return return
case .unavailable(.noFullAccess): case .unavailable(.noFullAccess):
@@ -348,18 +404,43 @@ final class KeyboardFlowCoordinator {
state.phase = .error(.fullAccessRequired, message: msg) state.phase = .error(.fullAccessRequired, message: msg)
scheduleAutoClearError() scheduleAutoClearError()
recomputeMicVoiceAvailability() recomputeMicVoiceAvailability()
return
case .unavailable(.appGroupUnavailable): case .unavailable(.appGroupUnavailable):
let msg = ExtL10n.string("keyboard.error.appGroupCommunication") let msg = ExtL10n.string("keyboard.error.appGroupCommunication")
state.phase = .error(.appGroupUnavailable, message: msg) state.phase = .error(.appGroupUnavailable, message: msg)
scheduleAutoClearError() scheduleAutoClearError()
recomputeMicVoiceAvailability() 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() detectAndStoreAppContext()
beginFlowStart() startFlowRecording()
case .unavailable(.hostNotReady): case .waitForHostReady(let recordWhenReady):
detectAndStoreAppContext() detectAndStoreAppContext()
beginFlowStart() recordWhenHostReady = recordWhenReady
case .recording, .processing: coldStartDebouncer.reset()
startHostReadyWaitIfNeeded()
traceState(
"pressBegan.waitForHostReady",
extra: recordWhenReady ? "recordWhenReady=1" : "recordWhenReady=0"
)
case .openHostColdStart:
detectAndStoreAppContext()
beginFlowStart(recordAfterHandoff: true)
case .ignore:
return return
} }
} }
@@ -374,19 +455,23 @@ final class KeyboardFlowCoordinator {
isFlowRecording = false isFlowRecording = false
stopUtteranceCountdown() stopUtteranceCountdown()
ExtensionScreenWakeLock.release() ExtensionScreenWakeLock.release()
lastStoppedUtteranceId = currentUtteranceId
writeCommand(.stopRecording) writeCommand(.stopRecording)
debug("pressEnded wrote stop command") debug("pressEnded wrote stop command")
state.phase = .processing state.phase = .processing
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing") state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
recomputeMicVoiceAvailability()
startFlowResultWatchdog() startFlowResultWatchdog()
recomputeMicVoiceAvailability()
} }
func beginFlowStart() { func beginFlowStart(recordAfterHandoff: Bool = false) {
guard !isPendingFlowStart else { guard !isPendingFlowStart else {
traceState("beginFlowStart.ignored", extra: "reason=pendingAlreadyTrue") traceState("beginFlowStart.ignored", extra: "reason=pendingAlreadyTrue")
return return
} }
self.recordAfterHandoff = recordAfterHandoff
recordWhenHostReady = false
coldStartDebouncer.reset()
isPendingFlowStart = true isPendingFlowStart = true
isFlowRecording = false isFlowRecording = false
flowStartDeadline = Date().timeIntervalSince1970 + FlowWatchdog.startTimeout flowStartDeadline = Date().timeIntervalSince1970 + FlowWatchdog.startTimeout
@@ -394,7 +479,10 @@ final class KeyboardFlowCoordinator {
recomputeMicVoiceAvailability() recomputeMicVoiceAvailability()
openHostApp("startflow") openHostApp("startflow")
startFlowStartWatchdog() startFlowStartWatchdog()
traceState("beginFlowStart.started") traceState(
"beginFlowStart.started",
extra: recordAfterHandoff ? "recordAfterHandoff=1" : "recordAfterHandoff=0"
)
} }
func handleHostAppOpenResult(path: String, success: Bool) { func handleHostAppOpenResult(path: String, success: Bool) {
@@ -406,6 +494,7 @@ final class KeyboardFlowCoordinator {
// guide the user to open OSGKeyboard manually. // guide the user to open OSGKeyboard manually.
if path == "startflow", isPendingFlowStart { if path == "startflow", isPendingFlowStart {
isPendingFlowStart = false isPendingFlowStart = false
recordAfterHandoff = false
flowStartDeadline = 0 flowStartDeadline = 0
stopFlowWatchdog() stopFlowWatchdog()
traceState("openHostApp.failed", extra: "path=startflow cancelPending=1") traceState("openHostApp.failed", extra: "path=startflow cancelPending=1")
@@ -425,8 +514,10 @@ final class KeyboardFlowCoordinator {
ExtensionScreenWakeLock.release() ExtensionScreenWakeLock.release()
} }
currentUtteranceId = nil currentUtteranceId = nil
lastStoppedUtteranceId = nil
isFlowRecording = false isFlowRecording = false
isPendingFlowStart = false isPendingFlowStart = false
recordAfterHandoff = false
stopUtteranceCountdown() stopUtteranceCountdown()
stopFlowWatchdog() stopFlowWatchdog()
state.level = 0 state.level = 0
@@ -472,6 +563,7 @@ final class KeyboardFlowCoordinator {
) )
FlowSessionBridge.clearResult() FlowSessionBridge.clearResult()
lastConsumedUtteranceId = result.utteranceId lastConsumedUtteranceId = result.utteranceId
lastStoppedUtteranceId = nil
currentUtteranceId = nil currentUtteranceId = nil
textInserter.handleFlowTranscript( textInserter.handleFlowTranscript(
TranscriptionDelivery(text: text, polishWarning: result.warning) TranscriptionDelivery(text: text, polishWarning: result.warning)
@@ -483,6 +575,7 @@ final class KeyboardFlowCoordinator {
stopFlowWatchdog() stopFlowWatchdog()
FlowSessionBridge.clearResult() FlowSessionBridge.clearResult()
lastConsumedUtteranceId = result.utteranceId lastConsumedUtteranceId = result.utteranceId
lastStoppedUtteranceId = nil
currentUtteranceId = nil currentUtteranceId = nil
let error = FlowTranscriptionError( let error = FlowTranscriptionError(
message: result.text ?? ExtL10n.string("keyboard.flow.resultTimeout"), message: result.text ?? ExtL10n.string("keyboard.flow.resultTimeout"),
@@ -528,6 +621,7 @@ final class KeyboardFlowCoordinator {
ExtensionScreenWakeLock.release() ExtensionScreenWakeLock.release()
writeCommand(.abort) writeCommand(.abort)
currentUtteranceId = nil currentUtteranceId = nil
lastStoppedUtteranceId = nil
stopFlowWatchdog() stopFlowWatchdog()
state.level = 0 state.level = 0
state.phase = .idle state.phase = .idle
@@ -547,10 +641,12 @@ final class KeyboardFlowCoordinator {
isAwaitingFlowResult = false isAwaitingFlowResult = false
isFlowRecording = false isFlowRecording = false
isPendingFlowStart = false isPendingFlowStart = false
recordAfterHandoff = false
stopUtteranceCountdown() stopUtteranceCountdown()
ExtensionScreenWakeLock.release() ExtensionScreenWakeLock.release()
writeCommand(.abort) writeCommand(.abort)
currentUtteranceId = nil currentUtteranceId = nil
lastStoppedUtteranceId = nil
stopFlowWatchdog() stopFlowWatchdog()
state.level = 0 state.level = 0
let message = ExtL10n.string("keyboard.flow.hostDisconnected") let message = ExtL10n.string("keyboard.flow.hostDisconnected")
@@ -585,9 +681,29 @@ final class KeyboardFlowCoordinator {
private func startFlowRecording() { private func startFlowRecording() {
recomputeMicVoiceAvailability() recomputeMicVoiceAvailability()
guard state.micVoiceAvailability.isReady else { let withinReadyGrace = lastHostReadyAt > 0
traceState("startFlowRecording.blocked", extra: "availability=\(String(describing: state.micVoiceAvailability))") && (Date().timeIntervalSince1970 - lastHostReadyAt) <= Self.hostReadyGrace
beginFlowStart() 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 return
} }
isPendingFlowStart = false isPendingFlowStart = false
@@ -596,11 +712,23 @@ final class KeyboardFlowCoordinator {
guard let sessionId = FlowSessionBridge.readySnapshot()?.sessionId else { guard let sessionId = FlowSessionBridge.readySnapshot()?.sessionId else {
traceState("startFlowRecording.blocked", extra: "reason=missingSessionIdInReadySnapshot") 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 return
} }
activeSessionId = sessionId activeSessionId = sessionId
currentUtteranceId = UUID() currentUtteranceId = UUID()
lastStoppedUtteranceId = nil
writeCommand(.startRecording) writeCommand(.startRecording)
isFlowRecording = true isFlowRecording = true
state.lastTranscript = "" state.lastTranscript = ""
@@ -640,8 +768,12 @@ final class KeyboardFlowCoordinator {
private func cancelPendingFlowStart() { private func cancelPendingFlowStart() {
isPendingFlowStart = false isPendingFlowStart = false
recordAfterHandoff = false
recordWhenHostReady = false
flowStartDeadline = 0 flowStartDeadline = 0
coldStartDebouncer.reset()
stopFlowWatchdog() stopFlowWatchdog()
stopHostReadyWait()
state.phase = .idle state.phase = .idle
state.lastTranscript = "" state.lastTranscript = ""
recomputeMicVoiceAvailability() recomputeMicVoiceAvailability()
@@ -660,6 +792,7 @@ final class KeyboardFlowCoordinator {
let now = Date().timeIntervalSince1970 let now = Date().timeIntervalSince1970
if self.flowStartDeadline > 0, now > self.flowStartDeadline { if self.flowStartDeadline > 0, now > self.flowStartDeadline {
self.isPendingFlowStart = false self.isPendingFlowStart = false
self.recordAfterHandoff = false
self.flowStartDeadline = 0 self.flowStartDeadline = 0
self.traceState("startWatchdog.timeout") self.traceState("startWatchdog.timeout")
self.showManualOpenHint(path: "startflow") self.showManualOpenHint(path: "startflow")
@@ -671,13 +804,20 @@ final class KeyboardFlowCoordinator {
} }
private func completeFlowStartHandoff() { private func completeFlowStartHandoff() {
let shouldRecord = recordAfterHandoff
isPendingFlowStart = false isPendingFlowStart = false
recordAfterHandoff = false
flowStartDeadline = 0 flowStartDeadline = 0
stopFlowWatchdog() stopFlowWatchdog()
state.lastTranscript = "" state.lastTranscript = ""
refreshSessionState() refreshSessionState()
if shouldRecord {
startFlowRecording() startFlowRecording()
traceState("completeFlowStartHandoff.done") traceState("completeFlowStartHandoff.done", extra: "record=1")
} else {
recomputeMicVoiceAvailability()
traceState("completeFlowStartHandoff.done", extra: "record=0 warmOnly")
}
} }
private func startFlowLevelWatchdog() { private func startFlowLevelWatchdog() {
@@ -735,6 +875,7 @@ final class KeyboardFlowCoordinator {
) )
FlowSessionBridge.clearResult() FlowSessionBridge.clearResult()
self.lastConsumedUtteranceId = result.utteranceId self.lastConsumedUtteranceId = result.utteranceId
self.lastStoppedUtteranceId = nil
self.currentUtteranceId = nil self.currentUtteranceId = nil
self.debug("resultWatchdog consumed delivery len=\(text.count)") self.debug("resultWatchdog consumed delivery len=\(text.count)")
self.textInserter.handleFlowTranscript( self.textInserter.handleFlowTranscript(
@@ -747,6 +888,7 @@ final class KeyboardFlowCoordinator {
self.stopFlowWatchdog() self.stopFlowWatchdog()
FlowSessionBridge.clearResult() FlowSessionBridge.clearResult()
self.lastConsumedUtteranceId = result.utteranceId self.lastConsumedUtteranceId = result.utteranceId
self.lastStoppedUtteranceId = nil
self.currentUtteranceId = nil self.currentUtteranceId = nil
let error = FlowTranscriptionError( let error = FlowTranscriptionError(
message: result.text ?? ExtL10n.string("keyboard.flow.resultTimeout"), message: result.text ?? ExtL10n.string("keyboard.flow.resultTimeout"),
@@ -784,6 +926,7 @@ final class KeyboardFlowCoordinator {
self.isAwaitingFlowResult = false self.isAwaitingFlowResult = false
self.stopFlowWatchdog() self.stopFlowWatchdog()
self.currentUtteranceId = nil self.currentUtteranceId = nil
self.lastStoppedUtteranceId = nil
self.debug("resultWatchdog TIMEOUT after \(Int(resultTimeout))s — no result from host") self.debug("resultWatchdog TIMEOUT after \(Int(resultTimeout))s — no result from host")
let msg = ExtL10n.string("keyboard.flow.resultTimeout") let msg = ExtL10n.string("keyboard.flow.resultTimeout")
self.state.phase = .error(.flowResultTimeout, message: msg) self.state.phase = .error(.flowResultTimeout, message: msg)
+12 -11
View File
@@ -12,7 +12,7 @@
// (transcript preview) // (transcript preview)
// //
// mic (centred) action cluster: // 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 // 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 /// The side cursor-drag pads are SwiftUI layout wrappers around UIKit
/// pan recognizers, avoiding SwiftUI gesture delivery issues in /// pan recognizers, avoiding SwiftUI gesture delivery issues in
/// keyboard extensions. /// keyboard extensions.
@@ -225,13 +225,13 @@ public struct KeyboardRootView: View {
HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) { HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) {
if swapKeys { if swapKeys {
bottomReturnButton(disabled: editingBlocked)
bottomSpaceButton(disabled: editingBlocked) bottomSpaceButton(disabled: editingBlocked)
bottomReturnButton(disabled: editingBlocked)
bottomDeleteButton(disabled: editingBlocked) bottomDeleteButton(disabled: editingBlocked)
} else { } else {
bottomDeleteButton(disabled: editingBlocked) bottomDeleteButton(disabled: editingBlocked)
bottomSpaceButton(disabled: editingBlocked)
bottomReturnButton(disabled: editingBlocked) bottomReturnButton(disabled: editingBlocked)
bottomSpaceButton(disabled: editingBlocked)
} }
} }
.opacity(dragging ? 0 : 1) .opacity(dragging ? 0 : 1)
@@ -265,19 +265,20 @@ public struct KeyboardRootView: View {
RectangularToolbarButton(spaceStyle: true, label: "space", disabled: disabled) { RectangularToolbarButton(spaceStyle: true, label: "space", disabled: disabled) {
state.insertSpace() state.insertSpace()
} }
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
}
private func bottomReturnButton(disabled: Bool) -> some View {
RectangularToolbarButton(systemName: "return", label: "newline", disabled: disabled) {
state.insertNewline()
}
.frame( .frame(
width: KeyboardLayoutMetrics.bottomActionFixedWidth, width: KeyboardLayoutMetrics.bottomActionFixedWidth,
height: KeyboardLayoutMetrics.bottomActionRowHeight 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. /// Option C: block typing keys during the full voice-input pipeline.
private var voiceInputBlocksEditing: Bool { private var voiceInputBlocksEditing: Bool {
switch state.phase { switch state.phase {
@@ -10,6 +10,7 @@ import OSGKeyboardShared
private enum ToolbarButtonMetrics { private enum ToolbarButtonMetrics {
static let iconSize: CGFloat = 14 static let iconSize: CGFloat = 14
static let titleSize: CGFloat = 16
static let cornerRadius: CGFloat = 12 static let cornerRadius: CGFloat = 12
static let spaceBarCapsuleWidth: CGFloat = 31 static let spaceBarCapsuleWidth: CGFloat = 31
static let pressScale: CGFloat = 0.94 static let pressScale: CGFloat = 0.94
@@ -138,6 +139,7 @@ struct RectangularToolbarButton: View {
let systemName: String? let systemName: String?
let spaceStyle: Bool let spaceStyle: Bool
let title: String?
let label: String let label: String
let disabled: Bool let disabled: Bool
let action: () -> Void let action: () -> Void
@@ -145,14 +147,25 @@ struct RectangularToolbarButton: View {
init(systemName: String, label: String, disabled: Bool = false, action: @escaping () -> Void) { init(systemName: String, label: String, disabled: Bool = false, action: @escaping () -> Void) {
self.systemName = systemName self.systemName = systemName
self.spaceStyle = false self.spaceStyle = false
self.title = nil
self.label = label self.label = label
self.disabled = disabled self.disabled = disabled
self.action = action 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) { init(spaceStyle: Bool, label: String, disabled: Bool = false, action: @escaping () -> Void) {
self.systemName = nil self.systemName = nil
self.spaceStyle = spaceStyle self.spaceStyle = spaceStyle
self.title = nil
self.label = label self.label = label
self.disabled = disabled self.disabled = disabled
self.action = action self.action = action
@@ -170,6 +183,10 @@ struct RectangularToolbarButton: View {
Image(systemName: systemName) Image(systemName: systemName)
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold)) .font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
} else if let title {
Text(title)
.font(.system(size: ToolbarButtonMetrics.titleSize, weight: .semibold))
.foregroundStyle(palette.textPrimary)
} }
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
+7 -6
View File
@@ -25,6 +25,7 @@
"common.delete" = "Delete"; "common.delete" = "Delete";
"common.space" = "Space"; "common.space" = "Space";
"common.newline" = "Return"; "common.newline" = "Return";
"common.send" = "Send";
/* Privacy footnote (onboarding welcome page) */ /* Privacy footnote (onboarding welcome page) */
"privacy.audio.title" = "On-device transcription"; "privacy.audio.title" = "On-device transcription";
@@ -69,13 +70,13 @@
"settings.reset.title" = "Reset all settings?"; "settings.reset.title" = "Reset all settings?";
"settings.reset.message" = "API key, model, and base URL will be cleared."; "settings.reset.message" = "API key, model, and base URL will be cleared.";
"settings.reset.confirm" = "Reset all settings"; "settings.reset.confirm" = "Reset all settings";
"settings.engine.title" = "Recognition method"; "settings.engine.title" = "Speech transcription method";
"settings.engine.subtitle" = "Pick the recognition engine. Local engine does transcription only, no API key needed."; "settings.engine.subtitle" = "Local: built-in system transcription. Cloud: ASR transcription with optional LLM polish.";
"settings.engine.local.title" = "On-device recognition"; "settings.engine.local.title" = "On-device transcription";
"settings.engine.local.ios26" = "Always on-device, no network."; "settings.engine.local.ios26" = "Always on-device, no network.";
"settings.engine.local.legacy" = "On-device ASR. Transcription only, no polish."; "settings.engine.local.legacy" = "Transcribe with the built-in system speech service";
"settings.engine.cloud.title" = "Cloud recognition & polish"; "settings.engine.cloud.title" = "Cloud transcription";
"settings.engine.cloud.subtitle" = "ASR + LLM polish. API key required."; "settings.engine.cloud.subtitle" = "Cloud ASR transcription, with optional LLM polish";
"settings.provider.title" = "Provider"; "settings.provider.title" = "Provider";
"settings.provider.subtitle" = "Pick the LLM that polishes your dictation."; "settings.provider.subtitle" = "Pick the LLM that polishes your dictation.";
"settings.api.title" = "API"; "settings.api.title" = "API";
@@ -24,7 +24,8 @@
"common.cancel" = "取消"; "common.cancel" = "取消";
"common.delete" = "删除"; "common.delete" = "删除";
"common.space" = "空格"; "common.space" = "空格";
"common.newline" = "换行"; "common.newline" = "回车";
"common.send" = "发送";
/* Privacy footnote (onboarding welcome page) */ /* Privacy footnote (onboarding welcome page) */
"privacy.audio.title" = "支持本地转写"; "privacy.audio.title" = "支持本地转写";
@@ -69,13 +70,13 @@
"settings.reset.title" = "重置所有设置?"; "settings.reset.title" = "重置所有设置?";
"settings.reset.message" = "API key、model 和 base URL 都会被清空。"; "settings.reset.message" = "API key、model 和 base URL 都会被清空。";
"settings.reset.confirm" = "重置所有设置"; "settings.reset.confirm" = "重置所有设置";
"settings.engine.title" = "识别方式"; "settings.engine.title" = "语音转写方式";
"settings.engine.subtitle" = "选择识别方式。本地引擎无需 API Key,仅做语音转录。"; "settings.engine.subtitle" = "本地:系统内置转写;云端:ASR 转写,可用 LLM 润色。";
"settings.engine.local.title" = "本地识别"; "settings.engine.local.title" = "本地转写";
"settings.engine.local.ios26" = "始终端侧,无需联网。"; "settings.engine.local.ios26" = "始终端侧,无需联网。";
"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。"; "settings.engine.local.legacy" = "通过系统内置服务语音转写";
"settings.engine.cloud.title" = "云端识别与润色"; "settings.engine.cloud.title" = "云端转写";
"settings.engine.cloud.subtitle" = "ASR 转录 + LLM 润色,需要 API Key。"; "settings.engine.cloud.subtitle" = "通过云端 ASR 转写,可用 LLM 润色";
"settings.provider.title" = "云端引擎"; "settings.provider.title" = "云端引擎";
"settings.provider.subtitle" = "选择 LLM 提供商。"; "settings.provider.subtitle" = "选择 LLM 提供商。";
"settings.api.title" = "接口"; "settings.api.title" = "接口";
@@ -66,7 +66,7 @@ private struct FlowLiveActivityLockScreenView: View {
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
FlowLiveActivityBrandMark(height: 13) FlowLiveActivityBrandMark(height: 10.4)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text("OSGKeyboard") Text("OSGKeyboard")
.font(.headline) .font(.headline)
+40 -52
View File
@@ -22,6 +22,12 @@ struct DashboardView: View {
} }
var body: some View { var body: some View {
// 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(spacing: 0) {
VStack(alignment: .leading, spacing: Spacing.lg) { VStack(alignment: .leading, spacing: Spacing.lg) {
heroHeader heroHeader
@@ -31,8 +37,8 @@ struct DashboardView: View {
.padding(.horizontal, MacMetrics.pageHorizontalInset) .padding(.horizontal, MacMetrics.pageHorizontalInset)
.padding(.top, Spacing.sm) .padding(.top, Spacing.sm)
// Leftover window height splits evenly above / below the mic bar // Leftover window height splits evenly above / below the mic
// so spacing stays balanced at any window size. // bar so spacing stays balanced at any window size.
Spacer(minLength: Spacing.xs) Spacer(minLength: Spacing.xs)
BottomDictationBar(viewModel: viewModel) BottomDictationBar(viewModel: viewModel)
@@ -40,6 +46,9 @@ struct DashboardView: View {
Spacer(minLength: Spacing.xs) Spacer(minLength: Spacing.xs)
} }
.frame(maxWidth: .infinity, minHeight: proxy.size.height)
}
}
.onAppear { stats.reloadFromDisk() } .onAppear { stats.reloadFromDisk() }
.onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in .onReceive(NotificationCenter.default.publisher(for: .usageStatisticsDidSyncFromCloud)) { _ in
stats.reloadFromDisk() stats.reloadFromDisk()
@@ -76,50 +85,18 @@ struct DashboardView: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
// MARK: - Stats (hero word count, full width but content-height // MARK: - Stats (7-day chart + cumulative grid shared cluster)
// never stretched to match a taller sibling and left with dead air)
private var statCluster: some View { private var statCluster: some View {
VStack(spacing: Spacing.md) { UsageStatsCluster(
StatCard( layout: .split,
title: MacL10n.string("mac.stat.words", language: lang), language: lang,
value: UsageStatisticsStore.formatCount( points: stats.last7Days,
stats.dictationCharacterCount, dictationCharacterCount: stats.dictationCharacterCount,
language: lang dictationDurationSeconds: stats.dictationDurationSeconds,
), translationCharacterCount: stats.translationCharacterCount,
caption: MacL10n.string("mac.stat.transcribed", language: lang), dictionaryTermCount: viewModel.dictionaryTermCount
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"
)
}
}
} }
// MARK: - Dictation stage // MARK: - Dictation stage
@@ -220,18 +197,29 @@ struct BottomDictationBar: View {
} }
} }
} label: { } label: {
HStack(spacing: 6) { HStack(spacing: Spacing.sm) {
Image(systemName: "translate") Image(systemName: "translate")
Text(currentTranslationLabel)
.lineLimit(1)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 9, weight: .semibold))
}
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary) .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: 11, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.font(MacSettingsType.control)
.padding(.horizontal, Spacing.sm) .padding(.horizontal, Spacing.sm)
.padding(.vertical, 7) .frame(minHeight: MacMetrics.settingsControlHeight)
.background(palette.surfaceElevated, in: Capsule()) .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) .menuStyle(.borderlessButton)
.fixedSize() .fixedSize()
+428 -99
View File
@@ -14,8 +14,33 @@ import SwiftUI
/// Fixed metrics that keep every desktop surface on the same grid. /// Fixed metrics that keep every desktop surface on the same grid.
enum MacMetrics { enum MacMetrics {
/// Uniform max width for trailing text controls (API key / model field). /// Shared height for credential inputs and icon buttons matches the iOS
static let controlWidth: CGFloat = 240 /// 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. /// Sidebar width and the horizontal inset shared by brand, nav and footer.
static let sidebarWidth: CGFloat = 240 static let sidebarWidth: CGFloat = 240
/// Horizontal inset for sidebar chrome (nav rows, footer). The brand logo /// Horizontal inset for sidebar chrome (nav rows, footer). The brand logo
@@ -46,6 +71,55 @@ enum MacMetrics {
static let trafficLightInset: CGFloat = 28 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 // MARK: - Liquid Glass
private struct MacGlassSurface<S: Shape>: ViewModifier { private struct MacGlassSurface<S: Shape>: ViewModifier {
@@ -75,26 +149,296 @@ extension View {
// MARK: - Text field styling // MARK: - Text field styling
/// Text-field chrome aligned with native macOS Form controls: compact /// Theme-aware credential field chrome comfortable tap target,
/// height, text-background fill, hairline border. /// monospaced-friendly body size, and a fill/border that stays clearly
/// distinct from the section card in both appearances.
private struct MacFieldStyleModifier: ViewModifier { private struct MacFieldStyleModifier: ViewModifier {
@Environment(\.themePalette) private var palette @Environment(\.themePalette) private var palette
@Environment(\.colorScheme) private var colorScheme
var monospaced: Bool = false
func body(content: Content) -> some View { 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 content
.textFieldStyle(.plain) .textFieldStyle(.plain)
.font(TypeStyle.footnote) .font(monospaced ? TypeStyle.mono : TypeStyle.body)
.padding(.horizontal, 8) .foregroundStyle(palette.textPrimary)
.frame(height: 22) .padding(.horizontal, Spacing.sm)
.background(Color(nsColor: .textBackgroundColor), in: shape) .frame(minHeight: MacMetrics.settingsControlHeight)
.background(fieldFill, in: shape)
.overlay(shape.stroke(palette.divider, lineWidth: 0.5)) .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 { extension View {
/// Theme-aware text-field styling for the settings inputs. /// 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<Content: View>: 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<Control: View>: 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<Control: View>: 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<Content: View>: 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<Content: View>: 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<Content: View>: 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 // MARK: - Page header
@@ -169,95 +513,6 @@ struct MacCard<Content: View>: 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 // MARK: - Live waveform
/// Compact bar visualiser reacting to the input level while recording. /// Compact bar visualiser reacting to the input level while recording.
@@ -330,6 +585,30 @@ struct MacStatusFooter: View {
var body: some View { var body: some View {
HStack(spacing: Spacing.sm) { HStack(spacing: Spacing.sm) {
Spacer() 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( Label(
viewModel.isCloudMode viewModel.isCloudMode
? MacL10n.string("mac.mode.cloud", language: lang) ? MacL10n.string("mac.mode.cloud", language: lang)
@@ -360,6 +639,56 @@ struct MacStatusFooter: View {
.padding(.vertical, Spacing.sm) .padding(.vertical, Spacing.sm)
.animation(Motion.quick, value: viewModel.isCloudMode) .animation(Motion.quick, value: viewModel.isCloudMode)
.animation(Motion.quick, value: viewModel.config.translationTargetLocaleId) .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
} }
} }
+2 -1
View File
@@ -40,7 +40,8 @@ enum MacDictationPipeline {
/// Whether the active engine can surface `onPartial` text while recording. /// Whether the active engine can surface `onPartial` text while recording.
static func supportsLivePartials(store: AppGroupStore) -> Bool { static func supportsLivePartials(store: AppGroupStore) -> Bool {
if store.engineMode == "local" { return true } 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. /// Runs ASR then polish. Polish failures return cleaned raw ASR plus a warning.
+23 -2
View File
@@ -92,7 +92,9 @@ final class MacDictationViewModel: ObservableObject {
self.config = ProviderConfig(defaults: defaults) self.config = ProviderConfig(defaults: defaults)
self.usageStatistics = UsageStatisticsStore(defaults: defaults) self.usageStatistics = UsageStatisticsStore(defaults: defaults)
self.autoPasteEnabled = defaults.object(forKey: StoredKeys.autoPaste) as? Bool ?? true 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( self.hotkeyTrigger = MacHotkeyTrigger(
rawValue: defaults.string(forKey: StoredKeys.hotkeyTrigger) ?? "" rawValue: defaults.string(forKey: StoredKeys.hotkeyTrigger) ?? ""
) ?? .rightOption ) ?? .rightOption
@@ -284,7 +286,7 @@ final class MacDictationViewModel: ObservableObject {
do { do {
let result: MacDictationResult let result: MacDictationResult
if let liveTask { if let liveTask {
let capture = await liveTask.value let capture = await Self.awaitLiveCapture(liveTask)
let trimmedLive = capture.raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedLive = capture.raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !capture.shouldFallbackToBatch, !trimmedLive.isEmpty { if !capture.shouldFallbackToBatch, !trimmedLive.isEmpty {
if self.transcript.isEmpty { if self.transcript.isEmpty {
@@ -362,6 +364,25 @@ final class MacDictationViewModel: ObservableObject {
isStreamingPartial = false isStreamingPartial = false
} }
/// Hard timeout so a hung realtime ASR drain cannot leave `isProcessing` stuck.
private static func awaitLiveCapture(
_ task: Task<MacLiveASRCaptureResult, Never>
) 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. /// Stops an in-flight prepare, or finishes an active recording.
private func cancelOrFinishRecording() { private func cancelOrFinishRecording() {
if isRecording { if isRecording {
+6 -10
View File
@@ -98,7 +98,7 @@ struct MacDictionaryView: View {
private var list: some View { private var list: some View {
// Full-bleed ScrollView scrollbar on the detail pane's right edge. // Full-bleed ScrollView scrollbar on the detail pane's right edge.
ScrollView { ScrollView {
LazyVStack(alignment: .leading, spacing: Spacing.md) { LazyVStack(alignment: .leading, spacing: Spacing.xl) {
if sections.isEmpty { if sections.isEmpty {
MacCard { MacCard {
Text(MacL10n.string("mac.dict.noMatch", language: lang)) Text(MacL10n.string("mac.dict.noMatch", language: lang))
@@ -120,22 +120,18 @@ struct MacDictionaryView: View {
private func categorySection( private func categorySection(
_ section: (category: PersonalDictionary.Entry.Category, items: [PersonalDictionary.Entry]) _ section: (category: PersonalDictionary.Entry.Category, items: [PersonalDictionary.Entry])
) -> some View { ) -> some View {
VStack(alignment: .leading, spacing: Spacing.xs) { VStack(alignment: .leading, spacing: Spacing.sm) {
Text(MacL10n.string(section.category.labelKey, language: lang)) Text(MacL10n.string(section.category.labelKey, language: lang))
.font(TypeStyle.caption) .font(MacSettingsType.sectionTitle)
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textSecondary)
.textCase(.uppercase)
MacCard(padding: 0) { MacCard(padding: 0) {
VStack(spacing: 0) { VStack(spacing: 0) {
ForEach(Array(section.items.enumerated()), id: \.element.id) { index, entry in ForEach(section.items, id: \.id) { entry in
row(entry) row(entry)
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm) .padding(.vertical, Spacing.sm)
if index < section.items.count - 1 {
// Full-bleed like macOS list rows (not iOS inset separators).
Divider()
.overlay(palette.divider)
}
} }
} }
} }
+6 -10
View File
@@ -83,7 +83,7 @@ struct MacHistoryView: View {
// Full-bleed ScrollView scrollbar on the detail pane's right edge. // Full-bleed ScrollView scrollbar on the detail pane's right edge.
// Horizontal inset lives on the content so cards align with the title. // Horizontal inset lives on the content so cards align with the title.
ScrollView { ScrollView {
LazyVStack(alignment: .leading, spacing: Spacing.md) { LazyVStack(alignment: .leading, spacing: Spacing.xl) {
ForEach(historyStore.groupedByDay, id: \.day) { group in ForEach(historyStore.groupedByDay, id: \.day) { group in
daySection(group) daySection(group)
} }
@@ -94,22 +94,18 @@ struct MacHistoryView: View {
} }
private func daySection(_ group: (day: Date, items: [SpeechHistoryEntry])) -> some 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)) Text(Self.dayFormatter.string(from: group.day))
.font(TypeStyle.caption) .font(MacSettingsType.sectionTitle)
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textSecondary)
.textCase(.uppercase)
MacCard(padding: 0) { MacCard(padding: 0) {
VStack(spacing: 0) { VStack(spacing: 0) {
ForEach(Array(group.items.enumerated()), id: \.element.id) { index, entry in ForEach(group.items, id: \.id) { entry in
row(entry) row(entry)
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm) .padding(.vertical, Spacing.sm)
if index < group.items.count - 1 {
// Full-bleed like macOS list rows (not iOS inset separators).
Divider()
.overlay(palette.divider)
}
} }
} }
} }
+42 -94
View File
@@ -1,10 +1,8 @@
// MacICloudSyncRows.swift // MacICloudSyncRows.swift
// OSGKeyboard · Mac // OSGKeyboard · Mac
// //
// iCloud sync toggles for settings and personal dictionary same KVS // iCloud sync toggle for settings, history, API keys, and personal
// keys and merge rules as the iOS settings page. Rendered as native Form // dictionary same KVS keys and merge rules as the iOS settings page.
// rows (Toggle + optional action / error) so they sit inside a grouped
// `Form` section and match System Settings exactly.
import SwiftUI import SwiftUI
@@ -19,38 +17,51 @@ struct MacSettingsICloudSyncRow: View {
@State private var isSyncingNow = false @State private var isSyncingNow = false
var body: some View { var body: some View {
Toggle(isOn: toggleBinding) { MacProviderSettingRow(
VStack(alignment: .leading, spacing: 2) { title: MacL10n.string("mac.sync.settingsTitle", language: language),
Text(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)) Text(MacL10n.string("mac.sync.settingsSubtitle", language: language))
.font(TypeStyle.caption) .font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
} }
.tint(palette.accent)
.disabled(isApplyingToggle)
.onAppear { reloadFromStore() } .onAppear { reloadFromStore() }
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in .onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
reloadFromStore() reloadFromStore()
} }
.onReceive(NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)) { _ in
reloadFromStore()
}
if isEnabled { if isEnabled {
Button {
syncNow()
} label: {
HStack(spacing: Spacing.xs) { HStack(spacing: Spacing.xs) {
MacSettingsToolButton(
title: MacL10n.string("mac.sync.syncNow", language: language),
disabled: isSyncingNow || isApplyingToggle
) {
syncNow()
}
if isSyncingNow { ProgressView().controlSize(.small) } if isSyncingNow { ProgressView().controlSize(.small) }
Text(MacL10n.string("mac.sync.syncNow", language: language)) Spacer(minLength: 0)
} }
} .padding(.horizontal, MacMetrics.settingsCardInset)
.disabled(isSyncingNow || isApplyingToggle) .padding(.bottom, Spacing.sm)
} }
if let syncErrorMessage { if let syncErrorMessage {
Text(syncErrorMessage) Text(syncErrorMessage)
.font(TypeStyle.caption) .font(MacSettingsType.hint)
.foregroundStyle(palette.danger) .foregroundStyle(palette.danger)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, MacMetrics.settingsCardInset)
.padding(.bottom, Spacing.sm)
} }
} }
@@ -74,6 +85,19 @@ struct MacSettingsICloudSyncRow: View {
Task { Task {
do { do {
try await MacICloudSyncBootstrap.settingsSync.enableSync() 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() reloadFromStore()
} catch { } catch {
isEnabled = false 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<Bool> {
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
}
}
@@ -198,6 +198,9 @@ struct MacLocalASRModelSettingsView: View {
@ObservedObject var viewModel: MacDictationViewModel @ObservedObject var viewModel: MacDictationViewModel
@StateObject private var modelVM = MacLocalASRModelSettingsViewModel() @StateObject private var modelVM = MacLocalASRModelSettingsViewModel()
@Environment(\.themePalette) private var palette @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 } private var lang: AppUILanguage { viewModel.config.uiLanguage }
@@ -217,19 +220,24 @@ struct MacLocalASRModelSettingsView: View {
} }
private func modelPickerSection(catalog: LocalASRCatalogDocument) -> some View { private func modelPickerSection(catalog: LocalASRCatalogDocument) -> some View {
Section { MacSettingsSection(title: MacL10n.string("mac.localASR.models", language: lang)) {
VStack(spacing: MacMetrics.settingsRowGap) {
if let runtime = modelVM.currentRuntime(in: catalog) { if let runtime = modelVM.currentRuntime(in: catalog) {
LabeledContent(runtime.displayName) { MacFormSubtitleRow(title: runtime.displayName) {
Text( Text(
modelVM.isRuntimeInstalled(runtime) modelVM.isRuntimeInstalled(runtime)
? MacL10n.string("mac.localASR.installed", language: lang) ? MacL10n.string("mac.localASR.installed", language: lang)
: MacL10n.string("mac.localASR.notInstalled", language: lang) : MacL10n.string("mac.localASR.notInstalled", language: lang)
) )
.font(TypeStyle.body)
.foregroundStyle(palette.textSecondary)
} }
} }
ForEach(catalog.models) { model in ForEach(Array(catalog.models.enumerated()), id: \.element.id) { _, model in
modelRow(model) modelRow(model)
.frame(minHeight: MacMetrics.settingsRowMinHeight)
.padding(.horizontal, MacMetrics.settingsCardInset)
} }
if modelVM.isInstalling, if modelVM.isInstalling,
@@ -240,19 +248,26 @@ struct MacLocalASRModelSettingsView: View {
Text(modelVM.progressLabel(for: modelVM.installProgress, language: lang)) Text(modelVM.progressLabel(for: modelVM.installProgress, language: lang))
.font(TypeStyle.caption) .font(TypeStyle.caption)
} }
.frame(minHeight: MacMetrics.settingsRowMinHeight)
.padding(.horizontal, MacMetrics.settingsCardInset)
} }
if !modelVM.statusMessage.isEmpty { if !modelVM.statusMessage.isEmpty {
Text(modelVM.statusMessage) Text(modelVM.statusMessage)
.font(TypeStyle.caption) .font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.frame(minHeight: MacMetrics.settingsRowMinHeight)
.padding(.horizontal, MacMetrics.settingsCardInset)
} }
Button(MacL10n.string("mac.localASR.openStorage", language: lang)) { HStack(spacing: 0) {
MacSettingsToolButton(title: MacL10n.string("mac.localASR.openStorage", language: lang)) {
modelVM.revealStorageRoot() modelVM.revealStorageRoot()
} }
} header: { Spacer(minLength: 0)
Text(MacL10n.string("mac.localASR.models", language: lang)) }
.padding(.horizontal, MacMetrics.settingsCardInset)
}
} }
} }
@@ -305,7 +320,7 @@ struct MacLocalASRModelSettingsView: View {
.animation(Motion.soft, value: installed) .animation(Motion.soft, value: installed)
} }
} }
.padding(.vertical, 2) .padding(.vertical, 0)
} }
private func modelBadge(_ title: String, emphasized: Bool) -> some View { private func modelBadge(_ title: String, emphasized: Bool) -> some View {
@@ -329,49 +344,39 @@ struct MacLocalASRModelSettingsView: View {
installing: Bool installing: Bool
) -> some View { ) -> some View {
if installing { if installing {
HStack(spacing: Spacing.sm) { installProgressControl(for: model)
circularInstallProgress(for: model) .animation(Motion.quick, value: isHoveringProgress)
if modelVM.installProgress.phase == .downloading .animation(Motion.quick, value: modelVM.isDownloadPaused)
|| 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)
)
}
}
} else if installed { } 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) modelVM.deleteModel(model)
} }
.buttonStyle(.bordered)
.controlSize(.small)
.tint(palette.danger)
} else { } 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) 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 = { let fraction: Double = {
if modelVM.installProgress.phase == .downloading || modelVM.installProgress.phase == .paused, if pausable,
let received = modelVM.installProgress.bytesReceived, let received = modelVM.installProgress.bytesReceived,
let total = modelVM.installProgress.bytesTotal, let total = modelVM.installProgress.bytesTotal,
total > 0 { total > 0 {
@@ -379,6 +384,7 @@ struct MacLocalASRModelSettingsView: View {
} }
return modelVM.installProgress.fraction return modelVM.installProgress.fraction
}() }()
return ZStack { return ZStack {
Circle() Circle()
.stroke(palette.textTertiary.opacity(0.25), lineWidth: 3) .stroke(palette.textTertiary.opacity(0.25), lineWidth: 3)
@@ -387,7 +393,36 @@ struct MacLocalASRModelSettingsView: View {
.stroke(palette.accent, style: StrokeStyle(lineWidth: 3, lineCap: .round)) .stroke(palette.accent, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
.animation(Motion.instant, value: fraction) .animation(Motion.instant, value: fraction)
if modelVM.installProgress.phase == .paused {
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") Image(systemName: "pause.fill")
.font(.system(size: 10, weight: .bold)) .font(.system(size: 10, weight: .bold))
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
@@ -397,9 +432,6 @@ struct MacLocalASRModelSettingsView: View {
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
} }
} }
.frame(width: 36, height: 36)
.accessibilityLabel(modelVM.progressLabel(for: modelVM.installProgress, language: lang))
}
private func modelSubtitle(_ model: LocalASRModelDefinition, installed: Bool) -> String { private func modelSubtitle(_ model: LocalASRModelDefinition, installed: Bool) -> String {
let size = modelVM.formattedSize(model.sizeBytes) let size = modelVM.formattedSize(model.sizeBytes)
+8 -5
View File
@@ -422,12 +422,15 @@ struct MacOnboardingView: View {
private var cloudAPIFields: some View { private var cloudAPIFields: some View {
VStack(alignment: .leading, spacing: Spacing.md) { VStack(alignment: .leading, spacing: Spacing.md) {
Picker(MacL10n.string("mac.settings.service", language: lang), selection: providerBinding) { MacInlinePicker(
ForEach(viewModel.selectableProviders) { provider in selection: providerBinding,
Text(provider.name).tag(provider.id) options: viewModel.selectableProviders.map {
MacInlinePickerOption(
value: $0.id,
label: ProviderDisplayName.name(for: $0.id, language: lang)
)
} }
} )
.labelsHidden()
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
SecureField(text: $viewModel.config.apiKey, prompt: Text(verbatim: "sk-...")) { SecureField(text: $viewModel.config.apiKey, prompt: Text(verbatim: "sk-...")) {
-95
View File
@@ -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
}
}
-58
View File
@@ -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")
}
}
+5 -5
View File
@@ -38,7 +38,7 @@ struct MacRootView: View {
private var sidebar: some View { private var sidebar: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
brandHeader brandHeader
VStack(spacing: 4) { VStack(spacing: 6) {
ForEach(MacSection.allCases) { section in ForEach(MacSection.allCases) { section in
MacSidebarRow( MacSidebarRow(
section: section, section: section,
@@ -70,8 +70,8 @@ struct MacRootView: View {
} }
.padding(.leading, MacMetrics.sidebarContentInset) .padding(.leading, MacMetrics.sidebarContentInset)
.padding(.trailing, MacMetrics.sidebarInset) .padding(.trailing, MacMetrics.sidebarInset)
.padding(.top, Spacing.lg) .padding(.top, 30)
.padding(.bottom, Spacing.md) .padding(.bottom, 30)
} }
private var devicesFooter: some View { private var devicesFooter: some View {
@@ -127,10 +127,10 @@ private struct MacSidebarRow: View {
.foregroundStyle(isSelected ? palette.accent : palette.textPrimary) .foregroundStyle(isSelected ? palette.accent : palette.textPrimary)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Spacing.sm) .padding(.horizontal, Spacing.sm)
.padding(.vertical, 7) .padding(.vertical, 9)
.background( .background(
rowBackground, rowBackground,
in: RoundedRectangle(cornerRadius: 7, style: .continuous) in: RoundedRectangle(cornerRadius: 8, style: .continuous)
) )
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
+543
View File
@@ -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<Value: Hashable>: 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<Value: Hashable>: View {
@Binding var selection: Value
let options: [MacInlinePickerOption<Value>]
/// 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)
}
}
+238 -252
View File
@@ -1,10 +1,8 @@
// MacSettingsView.swift // MacSettingsView.swift
// OSGKeyboard · Mac // OSGKeyboard · Mac
// //
// Settings uses native grouped `Form` for correct control layout (Picker / // Settings uses native grouped `Form`. LLM / ASR provider blocks use a merged
// Toggle / LabeledContent). Title and Form share the same plain // `MacSettingsProviderCard` with responsive rows and theme-aware controls.
// `pageHorizontalInset` padding (Form scroll margins are zeroed first) so
// card chrome lines up with the page title.
import SwiftUI import SwiftUI
#if os(macOS) #if os(macOS)
@@ -20,8 +18,6 @@ struct MacSettingsView: View {
@AppStorage(MacOnboardingState.storageKey) @AppStorage(MacOnboardingState.storageKey)
private var hasCompletedMacOnboarding = true private var hasCompletedMacOnboarding = true
@State private var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted @State private var accessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
@State private var showProviderPicker = false
@State private var showAsrProviderPicker = false
private var lang: AppUILanguage { viewModel.config.uiLanguage } private var lang: AppUILanguage { viewModel.config.uiLanguage }
private let recognitionLocales: [(id: String, key: String, fallback: String)] = [ private let recognitionLocales: [(id: String, key: String, fallback: String)] = [
@@ -41,10 +37,10 @@ struct MacSettingsView: View {
subtitle: MacL10n.string("mac.page.settings.subtitle", language: lang) subtitle: MacL10n.string("mac.page.settings.subtitle", language: lang)
) )
Form { ScrollView {
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
generalSection generalSection
recognitionSection recognitionSection
polishProviderSection
if viewModel.config.engineMode == "cloud" { if viewModel.config.engineMode == "cloud" {
asrProviderSection asrProviderSection
.transition(.opacity) .transition(.opacity)
@@ -53,21 +49,13 @@ struct MacSettingsView: View {
MacLocalASRModelSettingsView(viewModel: viewModel) MacLocalASRModelSettingsView(viewModel: viewModel)
.transition(.opacity) .transition(.opacity)
} }
polishProviderSection
inputSection inputSection
legalSection legalSection
} }
.formStyle(.grouped) .padding(.horizontal, MacMetrics.pageHorizontalInset)
// Zero Form's own scroll margins, then inset via padding so the .padding(.bottom, Spacing.md)
// 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) .tint(palette.accent)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(palette.background) .background(palette.background)
@@ -80,79 +68,83 @@ struct MacSettingsView: View {
// MARK: - General // MARK: - General
private var generalSection: some View { private var generalSection: some View {
Section(MacL10n.string("mac.settings.general", language: lang)) { MacSettingsSection(title: MacL10n.string("mac.settings.general", language: lang)) {
Picker(MacL10n.string("mac.settings.appearance", language: lang), selection: $appearanceRaw) { VStack(spacing: MacMetrics.settingsRowGap) {
ForEach(MacAppearancePreference.allCases) { pref in MacProviderSettingRow(title: MacL10n.string("mac.settings.appearance", language: lang)) {
Text(MacL10n.string(pref.labelKey, language: lang)).tag(pref.rawValue) 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) { MacProviderSettingRow(title: MacL10n.string("mac.settings.interfaceLanguage", language: lang)) {
ForEach(AppUILanguage.allCases, id: \.self) { language in MacInlinePicker(
Text(MacL10n.string(language.labelKey, language: lang)).tag(language.rawValue) 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) { MacProviderSettingRow(title: MacL10n.string("mac.settings.recognitionLanguage", language: lang)) {
ForEach(recognitionLocales, id: \.id) { locale in MacInlinePicker(
Text(localeLabel(locale)).tag(locale.id) selection: recognitionLanguageBinding,
} options: recognitionLocales.map {
MacInlinePickerOption(value: $0.id, label: localeLabel($0))
},
fillsWidth: true
)
} }
MacSettingsICloudSyncRow(defaults: viewModel.defaults, language: lang) MacSettingsICloudSyncRow(defaults: viewModel.defaults, language: lang)
MacDictionaryICloudSyncRow(defaults: viewModel.defaults, language: lang) }
} }
} }
// MARK: - Polish LLM // MARK: - Polish LLM
private var polishProviderSection: some View { private var polishProviderSection: some View {
Section(MacL10n.string("mac.settings.polishProvider", language: lang)) { MacSettingsSection(title: MacL10n.string("mac.settings.polishProvider", language: lang)) {
providerPickerRow( MacSettingsProviderCard {
MacProviderPickerRow(
title: MacL10n.string("mac.settings.service", language: lang), title: MacL10n.string("mac.settings.service", language: lang),
provider: currentPolishProvider,
isPresented: $showProviderPicker
) {
providerPickerList(
providers: viewModel.polishSelectableProviders, providers: viewModel.polishSelectableProviders,
selectedId: viewModel.config.providerId selection: polishProviderBinding
) { provider in )
viewModel.selectProvider(provider) MacCredentialField(
showProviderPicker = false title: MacL10n.string("mac.settings.apiKey", language: lang),
} placeholder: "sk-…",
} text: $viewModel.config.apiKey,
isSecret: true
LabeledContent { )
SecureField(text: $viewModel.config.apiKey, prompt: Text(verbatim: "sk-…")) { MacCredentialField(
Text(MacL10n.string("mac.settings.apiKey", language: lang)) title: MacL10n.string("mac.settings.baseURL", language: lang),
} placeholder: currentPolishProvider.defaultBaseURL,
.labelsHidden() text: $viewModel.config.baseURL
.macFieldStyle() )
.frame(maxWidth: MacMetrics.controlWidth) MacProviderModelRow(
} label: { title: MacL10n.string("mac.settings.model", language: lang),
Text(MacL10n.string("mac.settings.apiKey", language: lang)) placeholder: currentPolishProvider.defaultModel,
} model: $viewModel.config.model,
apiKey: viewModel.config.apiKey,
LabeledContent { fetchModels: fetchMacLLMModels,
TextField(text: $viewModel.config.baseURL, prompt: Text(verbatim: "")) { language: lang
Text(MacL10n.string("mac.settings.baseURL", language: lang)) )
} .id(viewModel.config.providerId)
.labelsHidden() MacProviderThinkingRow(
.macFieldStyle() title: MacL10n.string("mac.settings.thinking", language: lang),
.frame(maxWidth: MacMetrics.controlWidth) subtitle: MacL10n.string("mac.settings.thinkingSubtitle", language: lang),
} label: { isOn: $viewModel.config.llmThinkingEnabled
Text(MacL10n.string("mac.settings.baseURL", language: lang)) )
} MacProviderToolsRow(
title: MacL10n.string("mac.settings.connectionCheck", language: lang),
LabeledContent { validate: validateMacLLM,
TextField(text: $viewModel.config.model, prompt: Text(verbatim: "")) { language: lang
Text(MacL10n.string("mac.settings.model", language: lang)) )
}
.labelsHidden()
.macFieldStyle()
.frame(maxWidth: MacMetrics.controlWidth)
} label: {
Text(MacL10n.string("mac.settings.model", language: lang))
} }
} }
} }
@@ -160,94 +152,97 @@ struct MacSettingsView: View {
// MARK: - Cloud ASR // MARK: - Cloud ASR
private var asrProviderSection: some View { private var asrProviderSection: some View {
Section(MacL10n.string("mac.settings.asrProvider", language: lang)) { MacSettingsSection(title: MacL10n.string("mac.settings.asrProvider", language: lang)) {
providerPickerRow( MacSettingsProviderCard {
MacProviderPickerRow(
title: MacL10n.string("mac.settings.asrService", language: lang), title: MacL10n.string("mac.settings.asrService", language: lang),
provider: currentAsrProvider,
isPresented: $showAsrProviderPicker
) {
providerPickerList(
providers: viewModel.asrSelectableProviders, providers: viewModel.asrSelectableProviders,
selectedId: viewModel.config.asrProviderId selection: asrProviderBinding
) { provider in )
viewModel.selectAsrProvider(provider) if viewModel.config.asrProviderId == "volcengine" {
showAsrProviderPicker = false volcengineAsrRows
} else {
genericAsrRows
} }
} MacProviderToolsRow(
title: MacL10n.string("mac.settings.connectionCheck", language: lang),
LabeledContent { validate: validateMacASR,
SecureField(text: $viewModel.config.asrApiKey, prompt: Text(verbatim: "sk-…")) { language: lang
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))
} }
} }
} }
private func providerPickerRow<Content: View>( @ViewBuilder
title: String, private var volcengineAsrRows: some View {
provider: LLMProvider, MacCredentialField(
isPresented: Binding<Bool>, title: MacL10n.string("mac.settings.volcengineAppId", language: lang),
@ViewBuilder picker: @escaping () -> Content placeholder: "APP ID",
) -> some View { text: Binding(
LabeledContent(title) { get: { macVolcengineFields.appID },
Button { set: { updateMacVolcengine(appID: $0) }
isPresented.wrappedValue = true ),
} label: { isSecret: true
HStack(spacing: 6) { )
providerLogo(provider.id) MacCredentialField(
Text(provider.name) title: MacL10n.string("mac.settings.volcengineAccessToken", language: lang),
.foregroundStyle(palette.textPrimary) placeholder: "Access Token",
Image(systemName: "chevron.up.chevron.down") text: Binding(
.font(.system(size: 9, weight: .semibold)) get: { macVolcengineFields.accessToken },
.foregroundStyle(palette.textTertiary) 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))
} }
.contentShape(Rectangle())
} @ViewBuilder
.buttonStyle(.plain) private var genericAsrRows: some View {
.popover(isPresented: isPresented, arrowEdge: .bottom, content: picker) 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 // MARK: - Recognition method
private var recognitionSection: some View { private var recognitionSection: some View {
Section(MacL10n.string("mac.settings.recognition", language: lang)) { MacSettingsSection(title: MacL10n.string("mac.settings.recognition", language: lang)) {
VStack(spacing: MacMetrics.settingsRowGap) {
methodRow( methodRow(
title: MacL10n.string("mac.settings.cloudEngine", language: lang), title: MacL10n.string("mac.settings.cloudEngine", language: lang),
subtitle: MacL10n.string("mac.settings.cloudEngineDesc", language: lang), subtitle: MacL10n.string("mac.settings.cloudEngineDesc", language: lang),
systemImage: "cloud", systemImage: "cloud",
selected: viewModel.config.engineMode == "cloud" selected: viewModel.config.engineMode == "cloud"
) { withAnimation(Motion.soft) { viewModel.setEngineMode("cloud") } } ) { withAnimation(Motion.soft) { viewModel.setEngineMode("cloud") } }
methodRow( methodRow(
title: MacL10n.string("mac.settings.localEngine", language: lang), title: MacL10n.string("mac.settings.localEngine", language: lang),
subtitle: MacL10n.string("mac.settings.localEngineDesc", language: lang), subtitle: MacL10n.string("mac.settings.localEngineDesc", language: lang),
@@ -256,40 +251,39 @@ struct MacSettingsView: View {
) { withAnimation(Motion.soft) { viewModel.setEngineMode("local") } } ) { withAnimation(Motion.soft) { viewModel.setEngineMode("local") } }
} }
} }
}
// MARK: - Hotkey / paste // MARK: - Hotkey / paste
private var inputSection: some View { private var inputSection: some View {
Section(MacL10n.string("mac.settings.input", language: lang)) { MacSettingsSection(title: MacL10n.string("mac.settings.input", language: lang)) {
Toggle(isOn: hotkeyBinding) { VStack(spacing: MacMetrics.settingsRowGap) {
rowLabel( MacProviderSettingRow(title: MacL10n.string("mac.settings.hotkeyTrigger", language: lang)) {
MacL10n.string("mac.settings.hotkey", language: lang), MacInlinePicker(
subtitle: MacL10n.string("mac.settings.hotkeyDesc", language: lang) selection: hotkeyTriggerBinding,
options: MacHotkeyTrigger.allCases.map {
MacInlinePickerOption(value: $0.rawValue, label: MacL10n.string($0.labelKey, language: lang))
},
fillsWidth: true
) )
} }
MacProviderSettingRow(
Picker(selection: hotkeyTriggerBinding) { title: MacL10n.string("mac.settings.autoPaste", language: lang),
ForEach(MacHotkeyTrigger.allCases) { trigger in verticalAlignment: .center
Text(MacL10n.string(trigger.labelKey, language: lang)) ) {
.tag(trigger.rawValue) Toggle("", isOn: autoPasteBinding)
.labelsHidden()
.toggleStyle(MacToggleStyle())
} }
} label: { MacProviderSettingRow(
rowLabel( title: MacL10n.string("mac.settings.accessibility", language: lang),
MacL10n.string("mac.settings.hotkeyTrigger", language: lang), verticalAlignment: .center
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) { HStack(spacing: Spacing.sm) {
MacSettingsToolButton(title: MacL10n.string("mac.settings.openAccessibility", language: lang)) {
openAccessibilitySettings()
}
Label( Label(
accessibilityTrusted ? accessibilityStatusGranted : accessibilityStatusNeeded, accessibilityTrusted ? accessibilityStatusGranted : accessibilityStatusNeeded,
systemImage: accessibilityTrusted ? "checkmark.circle.fill" : "exclamationmark.circle" systemImage: accessibilityTrusted ? "checkmark.circle.fill" : "exclamationmark.circle"
@@ -298,16 +292,8 @@ struct MacSettingsView: View {
.foregroundStyle(accessibilityTrusted ? palette.accent : palette.warning) .foregroundStyle(accessibilityTrusted ? palette.accent : palette.warning)
.contentTransition(.opacity) .contentTransition(.opacity)
.animation(Motion.quick, value: accessibilityTrusted) .animation(Motion.quick, value: accessibilityTrusted)
Button(MacL10n.string("mac.settings.openAccessibility", language: lang)) {
openAccessibilitySettings()
} }
} }
} 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 // MARK: - Legal
private var legalSection: some View { private var legalSection: some View {
Section(MacL10n.string("mac.settings.about", language: lang)) { MacSettingsSection(title: MacL10n.string("mac.settings.about", language: lang)) {
VStack(spacing: MacMetrics.settingsRowGap) {
NavigationLink { NavigationLink {
MacPrivacyPolicyView(uiLanguage: lang) MacPrivacyPolicyView(uiLanguage: lang)
} label: { } label: {
Text(MacL10n.string("mac.settings.privacyPolicy", language: lang)) MacFormLinkRow(title: MacL10n.string("mac.settings.privacyPolicy", language: lang))
} }
.buttonStyle(.plain)
NavigationLink { NavigationLink {
MacOpenSourceLicensesView(uiLanguage: lang) MacOpenSourceLicensesView(uiLanguage: lang)
} label: { } label: {
Text(MacL10n.string("mac.settings.thirdPartyLicenses", language: lang)) MacFormLinkRow(title: MacL10n.string("mac.settings.thirdPartyLicenses", language: lang))
} }
.buttonStyle(.plain)
Button(MacL10n.string("mac.settings.restartOnboarding", language: lang)) { Button(MacL10n.string("mac.settings.restartOnboarding", language: lang)) {
hasCompletedMacOnboarding = false hasCompletedMacOnboarding = false
} }
.buttonStyle(.plain)
.frame(maxWidth: .infinity, minHeight: MacMetrics.settingsRowMinHeight, alignment: .leading)
.padding(.horizontal, MacMetrics.settingsCardInset)
}
} }
} }
// MARK: - Row helpers // 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( private func methodRow(
title: String, title: String,
subtitle: String, subtitle: String,
@@ -372,6 +353,8 @@ struct MacSettingsView: View {
.foregroundStyle(selected ? palette.accent : palette.textTertiary) .foregroundStyle(selected ? palette.accent : palette.textTertiary)
.contentTransition(.symbolEffect(.replace)) .contentTransition(.symbolEffect(.replace))
} }
.frame(minHeight: MacMetrics.settingsRowMinHeight)
.padding(.horizontal, MacMetrics.settingsCardInset)
.animation(Motion.quick, value: selected) .animation(Motion.quick, value: selected)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
@@ -390,60 +373,59 @@ struct MacSettingsView: View {
?? LLMProvider.presets[0] ?? LLMProvider.presets[0]
} }
private func providerPickerList( private var macVolcengineFields: VolcengineASRFields {
providers: [LLMProvider], VolcengineASRFields.parse(
selectedId: String, apiKey: viewModel.config.asrApiKey,
onSelect: @escaping (LLMProvider) -> Void resourceFallback: viewModel.config.asrModel.isEmpty
) -> some View { ? CloudASRModelCatalog.defaultModel(for: "volcengine")
VStack(spacing: 0) { : viewModel.config.asrModel
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)
} }
/// Brand mark tinted to the current label colour (black on light / white private func updateMacVolcengine(appID: String? = nil, accessToken: String? = nil, resourceID: String? = nil) {
/// on dark). Template rendering + an explicit frame make the vector assets var fields = macVolcengineFields
/// resolve at a text-matched size inside the pop-up menu without a size if let appID { fields.appID = appID }
/// hint they collapse to zero and disappear. if let accessToken { fields.accessToken = accessToken }
@ViewBuilder if let resourceID {
private func providerLogo(_ providerId: String) -> some View { fields.resourceID = resourceID
if let asset = ProviderLogo.assetName(for: providerId) { viewModel.config.asrModel = resourceID
Image(asset)
.resizable()
.scaledToFit()
.frame(width: 16, height: 16)
.foregroundStyle(palette.textPrimary)
} }
viewModel.config.asrApiKey = fields.encodedAPIKey
} }
@ViewBuilder private func validateMacLLM() async throws {
private func providerLabel(_ provider: LLMProvider) -> some View { let client = LLMClientFactory.make(
Label { providerId: viewModel.config.providerId,
Text(provider.name) baseURL: viewModel.config.baseURL,
} icon: { apiKey: viewModel.config.apiKey,
providerLogo(provider.id) 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 // MARK: - Bindings
@@ -455,17 +437,28 @@ struct MacSettingsView: View {
) )
} }
private var providerBinding: Binding<String> { private var polishProviderBinding: Binding<String> {
Binding( Binding(
get: { viewModel.config.providerId }, get: { viewModel.config.providerId },
set: { newId in 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) viewModel.selectProvider(provider)
} }
} }
) )
} }
private var asrProviderBinding: Binding<String> {
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<String> { private var recognitionLanguageBinding: Binding<String> {
Binding( Binding(
get: { get: {
@@ -478,13 +471,6 @@ struct MacSettingsView: View {
) )
} }
private var hotkeyBinding: Binding<Bool> {
Binding(
get: { viewModel.hotkeyEnabled },
set: { viewModel.setHotkeyEnabled($0) }
)
}
private var hotkeyTriggerBinding: Binding<String> { private var hotkeyTriggerBinding: Binding<String> {
Binding( Binding(
get: { viewModel.hotkeyTrigger.rawValue }, get: { viewModel.hotkeyTrigger.rawValue },
+20
View File
@@ -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
}
}
@@ -27,6 +27,7 @@ public protocol ConfigurationStore: Sendable {
var engineMode: String { get } var engineMode: String { get }
var polishIntensity: PolishIntensity { get } var polishIntensity: PolishIntensity { get }
var llmThinkingEnabled: Bool { get }
var personalDictionary: PersonalDictionary { get } var personalDictionary: PersonalDictionary { get }
/// Foreground-app context for polish prompts (keyboard extension publishes this). /// Foreground-app context for polish prompts (keyboard extension publishes this).
@@ -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
)
}
}
@@ -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)
}
}
}
+20 -4
View File
@@ -162,10 +162,13 @@ public enum Radius {
// MARK: - Typography // MARK: - Typography
public enum SettingsListMetrics { public enum SettingsListMetrics {
/// Single-line list rows (provider, footer link, picker). /// Floor for settings list rows (slightly above HIG 44pt).
public static let singleLineMinHeight: CGFloat = 52 /// Row height grows with content; this only enforces a touch-target minimum.
/// Two-line rows (engine option, labeled API field). public static let singleLineMinHeight: CGFloat = 48
public static let doubleLineMinHeight: CGFloat = 72 /// 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. /// Space between a section label and its card.
public static let sectionLabelSpacing: CGFloat = Spacing.sm public static let sectionLabelSpacing: CGFloat = Spacing.sm
} }
@@ -263,6 +266,19 @@ private struct SecondaryButtonModifier: ViewModifier {
} }
public extension View { 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. /// Standard card surface used in the main app.
func cardSurface(padding: CGFloat = Spacing.md) -> some View { func cardSurface(padding: CGFloat = Spacing.md) -> some View {
modifier(CardSurfaceModifier(padding: padding)) modifier(CardSurfaceModifier(padding: padding))
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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<Content: View>: 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)
)
}
}
@@ -8,6 +8,11 @@
import Foundation import Foundation
public struct AppGroupConfiguration: Sendable, Equatable { 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 // MARK: - Keys
public enum Keys { public enum Keys {
@@ -31,6 +36,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public static let handednessPreference = "config.handednessPreference" public static let handednessPreference = "config.handednessPreference"
public static let cursorDragNavigationEnabled = "config.cursorDragNavigationEnabled" public static let cursorDragNavigationEnabled = "config.cursorDragNavigationEnabled"
public static let polishIntensity = "config.polishIntensity" public static let polishIntensity = "config.polishIntensity"
public static let llmThinkingEnabled = "config.llmThinkingEnabled"
public static let detectedAppContext = "config.detectedAppContext" public static let detectedAppContext = "config.detectedAppContext"
public static let detectedAppContextAt = "config.detectedAppContextAt" public static let detectedAppContextAt = "config.detectedAppContextAt"
public static let personalDictionary = "config.personalDictionary.v1" public static let personalDictionary = "config.personalDictionary.v1"
@@ -70,6 +76,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public var handednessPreference: HandednessPreference public var handednessPreference: HandednessPreference
public var cursorDragNavigationEnabled: Bool public var cursorDragNavigationEnabled: Bool
public var polishIntensity: PolishIntensity public var polishIntensity: PolishIntensity
/// Enables provider-specific reasoning / thinking controls for polish LLM requests.
public var llmThinkingEnabled: Bool
public var personalDictionary: PersonalDictionary public var personalDictionary: PersonalDictionary
/// Opt-in iCloud KVS sync for the personal dictionary (main app only). /// Opt-in iCloud KVS sync for the personal dictionary (main app only).
public var personalDictionaryICloudSyncEnabled: Bool public var personalDictionaryICloudSyncEnabled: Bool
@@ -150,7 +158,9 @@ public struct AppGroupConfiguration: Sendable, Equatable {
OpenAICompatibleClient( OpenAICompatibleClient(
baseURL: baseURL, baseURL: baseURL,
apiKey: apiKey, 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. /// Loads configuration from a known-available UserDefaults suite.
public static func load(fromAvailable defaults: UserDefaults) -> AppGroupConfiguration { public static func load(fromAvailable defaults: UserDefaults) -> AppGroupConfiguration {
let storedProviderId = defaults.string(forKey: Keys.providerId)
var config = AppGroupConfiguration( var config = AppGroupConfiguration(
providerId: defaults.string(forKey: Keys.providerId) ?? "openai", providerId: storedProviderId ?? defaultPolishProviderId,
baseURL: "", baseURL: "",
model: "", model: "",
asrProviderId: defaults.string(forKey: Keys.asrProviderId) ?? "", asrProviderId: defaults.string(forKey: Keys.asrProviderId) ?? "",
@@ -228,6 +239,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
return defaults.bool(forKey: Keys.cursorDragNavigationEnabled) return defaults.bool(forKey: Keys.cursorDragNavigationEnabled)
}(), }(),
polishIntensity: resolvePolishIntensity(from: defaults), polishIntensity: resolvePolishIntensity(from: defaults),
llmThinkingEnabled: defaults.bool(forKey: Keys.llmThinkingEnabled),
personalDictionary: decodePersonalDictionary(from: defaults), personalDictionary: decodePersonalDictionary(from: defaults),
personalDictionaryICloudSyncEnabled: { personalDictionaryICloudSyncEnabled: {
if defaults.object(forKey: Keys.personalDictionaryICloudSyncEnabled) == nil { if defaults.object(forKey: Keys.personalDictionaryICloudSyncEnabled) == nil {
@@ -267,7 +279,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
} }
if config.asrProviderId.isEmpty { 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) defaults.set(config.asrProviderId, forKey: Keys.asrProviderId)
} }
let asrPreset = LLMProvider.provider(id: config.asrProviderId) let asrPreset = LLMProvider.provider(id: config.asrProviderId)
@@ -279,6 +292,17 @@ public struct AppGroupConfiguration: Sendable, Equatable {
?? CloudASRModelCatalog.defaultModel(for: config.asrProviderId) ?? 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. // One-shot legacy migration: plaintext apiKey in UserDefaults Keychain.
_ = resolveAPIKey( _ = resolveAPIKey(
defaults: defaults, defaults: defaults,
@@ -311,26 +335,6 @@ public struct AppGroupConfiguration: Sendable, Equatable {
config.modeId = "polish" config.modeId = "polish"
defaults.set("polish", forKey: Keys.modeId) 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 return config
} }
@@ -352,6 +356,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
defaults.set(handednessPreference.rawValue, forKey: Keys.handednessPreference) defaults.set(handednessPreference.rawValue, forKey: Keys.handednessPreference)
defaults.set(cursorDragNavigationEnabled, forKey: Keys.cursorDragNavigationEnabled) defaults.set(cursorDragNavigationEnabled, forKey: Keys.cursorDragNavigationEnabled)
defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity) defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity)
defaults.set(llmThinkingEnabled, forKey: Keys.llmThinkingEnabled)
defaults.set(flowSkipAppSwitch, forKey: Keys.flowSkipAppSwitch) defaults.set(flowSkipAppSwitch, forKey: Keys.flowSkipAppSwitch)
defaults.set(flowInactivityDuration.rawValue, forKey: Keys.flowInactivityDuration) defaults.set(flowInactivityDuration.rawValue, forKey: Keys.flowInactivityDuration)
defaults.set(localASRCustomLanguageModelEnabled, forKey: Keys.localASRCustomLanguageModelEnabled) defaults.set(localASRCustomLanguageModelEnabled, forKey: Keys.localASRCustomLanguageModelEnabled)
+64 -11
View File
@@ -10,10 +10,14 @@ import Foundation
public enum CloudASRStrategy: String, Sendable, Equatable { public enum CloudASRStrategy: String, Sendable, Equatable {
/// GLM-ASR `hotwords` + optional `prompt`. /// GLM-ASR `hotwords` + optional `prompt`.
case zhipuHotwords case zhipuHotwords
/// Fun-ASR managed `vocabulary_id` + context text. /// Fun-ASR Realtime DashScope inference WebSocket
case alibabaVocabulary case bailianStreaming
/// OpenAI / MiMo / `prompt` on transcription APIs. /// OpenAI / Groq / / Whisper `prompt` on transcription APIs.
case prompt case prompt
/// OpenRouter `/audio/transcriptions` JSON body + base64 WAV (not multipart).
case openRouterJson
/// SAUC ASRWebSocket + binary frame
case volcengineStreaming
/// Moonshot API 退 ASR /// Moonshot API 退 ASR
case localFallback case localFallback
} }
@@ -54,8 +58,23 @@ public enum CloudASRError: Error, LocalizedError, Sendable, Equatable {
} }
public enum CloudASRModelCatalog { public enum CloudASRModelCatalog {
/// Provider ids shown in the cloud ASR picker (explicit allowlist).
public static let selectableProviderIds: Set<String> = [
"openai",
"whisper",
"bailian",
"zhipu",
"groq",
"siliconflow",
"openrouter",
"mimo",
"volcengine",
"custom",
]
/// Sync Fun-ASR Flash base64 upload, 5 min, supports context + vocabulary. /// Sync Fun-ASR Flash base64 upload, 5 min, supports context + vocabulary.
public static let alibabaFunASRFlash = "fun-asr-flash-2026-06-15" 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. /// Must match the ASR model used at recognition time.
public static let alibabaVocabularyTargetModel = alibabaFunASRFlash public static let alibabaVocabularyTargetModel = alibabaFunASRFlash
@@ -63,24 +82,38 @@ public enum CloudASRModelCatalog {
public static let openAITranscribe = "gpt-4o-mini-transcribe" public static let openAITranscribe = "gpt-4o-mini-transcribe"
public static let openAIWhisper = "whisper-1" public static let openAIWhisper = "whisper-1"
public static let mimoASR = "mimo-v2.5-asr" 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 alibabaAPIBase = "https://dashscope.aliyuncs.com/api/v1"
public static let alibabaCustomizationPath = "/services/audio/asr/customization" public static let alibabaCustomizationPath = "/services/audio/asr/customization"
public static let alibabaMultimodalPath = "/services/aigc/multimodal-generation/generation" public static let alibabaMultimodalPath = "/services/aigc/multimodal-generation/generation"
public static let zhipuTranscriptionPath = "/audio/transcriptions" public static let zhipuTranscriptionPath = "/audio/transcriptions"
public static func supportsCloudASRSelection(providerId: String) -> Bool {
selectableProviderIds.contains(providerId)
}
public static func strategy(for providerId: String) -> CloudASRStrategy { public static func strategy(for providerId: String) -> CloudASRStrategy {
switch providerId { switch providerId {
case "zhipu": case "zhipu":
return .zhipuHotwords return .zhipuHotwords
case "qwen": case "bailian":
return .alibabaVocabulary return .bailianStreaming
case "moonshot": case "moonshot":
return .localFallback return .localFallback
case "openai", "mimo", "custom": case "volcengine":
return .volcengineStreaming
case "openrouter":
return .openRouterJson
case "openai", "whisper", "mimo", "groq", "siliconflow", "custom":
return .prompt return .prompt
default: default:
return .prompt return .localFallback
} }
} }
@@ -88,16 +121,36 @@ public enum CloudASRModelCatalog {
switch providerId { switch providerId {
case "zhipu": case "zhipu":
return zhipuGLMASR return zhipuGLMASR
case "qwen": case "bailian":
return alibabaFunASRFlash return alibabaFunASRRealtime
case "whisper":
return openAIWhisper
case "mimo": case "mimo":
return mimoASR return mimoASR
case "groq":
return groqWhisper
case "siliconflow":
return siliconflowASR
case "openrouter":
return openrouterWhisper
case "volcengine":
return volcengineDefaultResourceID
case "openai", "custom": case "openai", "custom":
return openAITranscribe return openAITranscribe
default: default:
return openAITranscribe 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 { extension LLMProvider {
@@ -112,9 +165,9 @@ extension LLMProvider {
/// Official hotwords / vocabulary APIs during cloud ASR (not prompt-only bias). /// Official hotwords / vocabulary APIs during cloud ASR (not prompt-only bias).
public var supportsPersonalDictionaryCloudASR: Bool { public var supportsPersonalDictionaryCloudASR: Bool {
switch cloudASRStrategy { switch cloudASRStrategy {
case .zhipuHotwords, .alibabaVocabulary: case .zhipuHotwords:
return true return true
case .prompt, .localFallback: case .bailianStreaming, .prompt, .openRouterJson, .volcengineStreaming, .localFallback:
return false return false
} }
} }
@@ -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
}
}
@@ -2,7 +2,7 @@
// OSGKeyboard · Shared // OSGKeyboard · Shared
// //
// Which hand the user holds the phone with controls bottom-row key order // 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 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 var swapsActionKeys: Bool { self == .right }
public static func fromStored(_ raw: String?) -> HandednessPreference { public static func fromStored(_ raw: String?) -> HandednessPreference {
+136 -9
View File
@@ -48,15 +48,21 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
apiKeyURL: URL(string: "https://platform.openai.com/api-keys"), apiKeyURL: URL(string: "https://platform.openai.com/api-keys"),
blurb: "GPT-4o mini · 多语言 · Multilingual" 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( .init(
id: "deepseek", id: "deepseek",
name: "DeepSeek", name: "DeepSeek",
defaultBaseURL: "https://api.deepseek.com/v1", defaultBaseURL: "https://api.deepseek.com/v1",
defaultModel: "deepseek-v4-flash", defaultModel: "deepseek-v4-flash",
apiKeyURL: URL(string: "https://platform.deepseek.com/api_keys"), apiKeyURL: URL(string: "https://platform.deepseek.com/api_keys"),
blurb: "deepseek-v4-flash · 本地引擎内置 · Local engine built-in", blurb: "deepseek-v4-flash · 本地引擎内置 · Local engine optional built-in"
// Local engine only never shown in cloud-engine pickers.
isUserSelectable: false
), ),
.init( .init(
id: "qwen", id: "qwen",
@@ -82,6 +88,30 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
apiKeyURL: URL(string: "https://platform.moonshot.cn/console/api-keys"), apiKeyURL: URL(string: "https://platform.moonshot.cn/console/api-keys"),
blurb: "Kimi · 长上下文 · Long context" 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( .init(
id: "mimo", id: "mimo",
name: "小米 MiMo", name: "小米 MiMo",
@@ -90,6 +120,106 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
apiKeyURL: URL(string: "https://platform.xiaomimimo.com"), apiKeyURL: URL(string: "https://platform.xiaomimimo.com"),
blurb: "mimo-v2.5 · 中文优化 · Chinese-optimized" 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( .init(
id: "custom", id: "custom",
name: "Custom · 自定义", name: "Custom · 自定义",
@@ -103,16 +233,13 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
presets.first(where: { $0.id == id }) ?? .presets[0] presets.first(where: { $0.id == id }) ?? .presets[0]
} }
/// Presets the user may pick in Settings / onboarding. DeepSeek is /// Presets the user may pick in Settings / onboarding.
/// excluded it is wired exclusively to the local engine.
public static var userSelectablePresets: [LLMProvider] { public static var userSelectablePresets: [LLMProvider] {
presets.filter(\.isUserSelectable) 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] { public static var asrSelectablePresets: [LLMProvider] {
userSelectablePresets.filter { presets.filter { CloudASRModelCatalog.supportsCloudASRSelection(providerId: $0.id) }
CloudASRModelCatalog.strategy(for: $0.id) != .localFallback
}
} }
} }
+42 -38
View File
@@ -111,7 +111,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
didSet { didSet {
guard !isApplyingConfiguration, engineMode != configuration.engineMode else { return } guard !isApplyingConfiguration, engineMode != configuration.engineMode else { return }
configuration.engineMode = engineMode configuration.engineMode = engineMode
applyEngineModeSideEffects()
persistConfiguration(postConfigChanged: true) 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 /// 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 { @Published public var handednessPreference: HandednessPreference {
didSet { didSet {
guard !isApplyingConfiguration, 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. /// When enabled, the host app tries to return to the source app after a cold-start handoff.
@Published public var flowSkipAppSwitch: Bool { @Published public var flowSkipAppSwitch: Bool {
didSet { didSet {
@@ -335,6 +345,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
handednessPreference = configuration.handednessPreference handednessPreference = configuration.handednessPreference
cursorDragNavigationEnabled = configuration.cursorDragNavigationEnabled cursorDragNavigationEnabled = configuration.cursorDragNavigationEnabled
polishIntensity = configuration.polishIntensity polishIntensity = configuration.polishIntensity
llmThinkingEnabled = configuration.llmThinkingEnabled
flowSkipAppSwitch = configuration.flowSkipAppSwitch flowSkipAppSwitch = configuration.flowSkipAppSwitch
flowInactivityDuration = configuration.flowInactivityDuration flowInactivityDuration = configuration.flowInactivityDuration
localASRCustomLanguageModelEnabled = configuration.localASRCustomLanguageModelEnabled localASRCustomLanguageModelEnabled = configuration.localASRCustomLanguageModelEnabled
@@ -347,15 +358,34 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
isApplyingConfiguration = false isApplyingConfiguration = false
} }
/// Keep cloud vs local provider choices isolated when the user public func reset() {
/// switches engines in Settings / onboarding. isApplyingConfiguration = true
private func applyEngineModeSideEffects() { let polishPreset = LLMProvider.provider(id: AppGroupConfiguration.defaultPolishProviderId)
if engineMode == "cloud", providerId == "deepseek" { let asrPreset = LLMProvider.provider(id: AppGroupConfiguration.defaultCloudASRProviderId)
apply(preset: LLMProvider.provider(id: "openai")) providerId = polishPreset.id
} baseURL = polishPreset.defaultBaseURL
if engineMode == "cloud", asrProviderId == "deepseek" { apiKey = ""
applyAsr(preset: LLMProvider.provider(id: "openai")) 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) { private func persistConfiguration(postConfigChanged: Bool = false) {
@@ -401,6 +431,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
handednessPreference = fresh.handednessPreference handednessPreference = fresh.handednessPreference
cursorDragNavigationEnabled = fresh.cursorDragNavigationEnabled cursorDragNavigationEnabled = fresh.cursorDragNavigationEnabled
polishIntensity = fresh.polishIntensity polishIntensity = fresh.polishIntensity
llmThinkingEnabled = fresh.llmThinkingEnabled
flowSkipAppSwitch = fresh.flowSkipAppSwitch flowSkipAppSwitch = fresh.flowSkipAppSwitch
flowInactivityDuration = fresh.flowInactivityDuration flowInactivityDuration = fresh.flowInactivityDuration
localASRCustomLanguageModelEnabled = fresh.localASRCustomLanguageModelEnabled localASRCustomLanguageModelEnabled = fresh.localASRCustomLanguageModelEnabled
@@ -455,31 +486,4 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
isApplyingConfiguration = false isApplyingConfiguration = false
persistConfiguration() 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()
}
} }
+14 -2
View File
@@ -10,12 +10,24 @@ public enum ProviderLogo {
/// Asset name for the provider's logo, or `nil` when there is no bundled logo. /// Asset name for the provider's logo, or `nil` when there is no bundled logo.
public static func assetName(for providerId: String) -> String? { public static func assetName(for providerId: String) -> String? {
switch providerId { switch providerId {
case "openai": return "openai" case "openai", "whisper": return "openai"
case "deepseek": return "deepseek" case "deepseek": return "deepseek"
case "qwen": return "qwen" case "qwen", "bailian", "alibabaCoding": return "qwen"
case "moonshot": return "moonshot" case "moonshot": return "moonshot"
case "zhipu": return "zhipu" case "zhipu": return "zhipu"
case "mimo": return "mimo" 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 "apple": return "apple"
case "custom": return "custom" case "custom": return "custom"
default: return nil default: return nil
@@ -27,6 +27,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
public var handednessPreference: SyncedField<HandednessPreference> public var handednessPreference: SyncedField<HandednessPreference>
public var cursorDragNavigationEnabled: SyncedField<Bool> public var cursorDragNavigationEnabled: SyncedField<Bool>
public var polishIntensity: SyncedField<PolishIntensity> public var polishIntensity: SyncedField<PolishIntensity>
public var llmThinkingEnabled: SyncedField<Bool>
public var flowSkipAppSwitch: SyncedField<Bool> public var flowSkipAppSwitch: SyncedField<Bool>
public var flowInactivityDuration: SyncedField<FlowInactivityDuration> public var flowInactivityDuration: SyncedField<FlowInactivityDuration>
@@ -47,6 +48,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
handednessPreference: SyncedField<HandednessPreference>, handednessPreference: SyncedField<HandednessPreference>,
cursorDragNavigationEnabled: SyncedField<Bool>, cursorDragNavigationEnabled: SyncedField<Bool>,
polishIntensity: SyncedField<PolishIntensity>, polishIntensity: SyncedField<PolishIntensity>,
llmThinkingEnabled: SyncedField<Bool>,
flowSkipAppSwitch: SyncedField<Bool>, flowSkipAppSwitch: SyncedField<Bool>,
flowInactivityDuration: SyncedField<FlowInactivityDuration> flowInactivityDuration: SyncedField<FlowInactivityDuration>
) { ) {
@@ -66,6 +68,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
self.handednessPreference = handednessPreference self.handednessPreference = handednessPreference
self.cursorDragNavigationEnabled = cursorDragNavigationEnabled self.cursorDragNavigationEnabled = cursorDragNavigationEnabled
self.polishIntensity = polishIntensity self.polishIntensity = polishIntensity
self.llmThinkingEnabled = llmThinkingEnabled
self.flowSkipAppSwitch = flowSkipAppSwitch self.flowSkipAppSwitch = flowSkipAppSwitch
self.flowInactivityDuration = flowInactivityDuration self.flowInactivityDuration = flowInactivityDuration
} }
@@ -87,6 +90,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
case handednessPreference case handednessPreference
case cursorDragNavigationEnabled case cursorDragNavigationEnabled
case polishIntensity case polishIntensity
case llmThinkingEnabled
case flowSkipAppSwitch case flowSkipAppSwitch
case flowInactivityDuration case flowInactivityDuration
} }
@@ -115,6 +119,10 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
forKey: .cursorDragNavigationEnabled forKey: .cursorDragNavigationEnabled
) )
polishIntensity = try container.decode(SyncedField<PolishIntensity>.self, forKey: .polishIntensity) polishIntensity = try container.decode(SyncedField<PolishIntensity>.self, forKey: .polishIntensity)
llmThinkingEnabled = try container.decodeIfPresent(
SyncedField<Bool>.self,
forKey: .llmThinkingEnabled
) ?? SyncedField(value: false, updatedAt: polishIntensity.updatedAt, deviceID: polishIntensity.deviceID)
flowSkipAppSwitch = try container.decode(SyncedField<Bool>.self, forKey: .flowSkipAppSwitch) flowSkipAppSwitch = try container.decode(SyncedField<Bool>.self, forKey: .flowSkipAppSwitch)
flowInactivityDuration = try container.decode( flowInactivityDuration = try container.decode(
SyncedField<FlowInactivityDuration>.self, SyncedField<FlowInactivityDuration>.self,
@@ -161,6 +169,7 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
handednessPreference.updatedAt, handednessPreference.updatedAt,
cursorDragNavigationEnabled.updatedAt, cursorDragNavigationEnabled.updatedAt,
polishIntensity.updatedAt, polishIntensity.updatedAt,
llmThinkingEnabled.updatedAt,
flowSkipAppSwitch.updatedAt, flowSkipAppSwitch.updatedAt,
flowInactivityDuration.updatedAt, flowInactivityDuration.updatedAt,
].max() ?? .distantPast ].max() ?? .distantPast
@@ -197,6 +206,7 @@ public extension SyncedAppSettingsV2 {
handednessPreference: field(configuration.handednessPreference), handednessPreference: field(configuration.handednessPreference),
cursorDragNavigationEnabled: field(configuration.cursorDragNavigationEnabled), cursorDragNavigationEnabled: field(configuration.cursorDragNavigationEnabled),
polishIntensity: field(configuration.polishIntensity), polishIntensity: field(configuration.polishIntensity),
llmThinkingEnabled: field(configuration.llmThinkingEnabled),
flowSkipAppSwitch: field(configuration.flowSkipAppSwitch), flowSkipAppSwitch: field(configuration.flowSkipAppSwitch),
flowInactivityDuration: field(configuration.flowInactivityDuration) flowInactivityDuration: field(configuration.flowInactivityDuration)
) )
@@ -225,6 +235,7 @@ public extension SyncedAppSettingsV2 {
handednessPreference: field(legacy.handednessPreference), handednessPreference: field(legacy.handednessPreference),
cursorDragNavigationEnabled: field(legacy.cursorDragNavigationEnabled), cursorDragNavigationEnabled: field(legacy.cursorDragNavigationEnabled),
polishIntensity: field(legacy.polishIntensity), polishIntensity: field(legacy.polishIntensity),
llmThinkingEnabled: field(false),
flowSkipAppSwitch: field(legacy.flowSkipAppSwitch), flowSkipAppSwitch: field(legacy.flowSkipAppSwitch),
flowInactivityDuration: field(legacy.flowInactivityDuration) flowInactivityDuration: field(legacy.flowInactivityDuration)
) )
@@ -256,6 +267,7 @@ public extension SyncedAppSettingsV2 {
remote: remote.cursorDragNavigationEnabled remote: remote.cursorDragNavigationEnabled
), ),
polishIntensity: .merge(local: local.polishIntensity, remote: remote.polishIntensity), polishIntensity: .merge(local: local.polishIntensity, remote: remote.polishIntensity),
llmThinkingEnabled: .merge(local: local.llmThinkingEnabled, remote: remote.llmThinkingEnabled),
flowSkipAppSwitch: .merge(local: local.flowSkipAppSwitch, remote: remote.flowSkipAppSwitch), flowSkipAppSwitch: .merge(local: local.flowSkipAppSwitch, remote: remote.flowSkipAppSwitch),
flowInactivityDuration: .merge( flowInactivityDuration: .merge(
local: local.flowInactivityDuration, local: local.flowInactivityDuration,
@@ -280,6 +292,7 @@ public extension SyncedAppSettingsV2 {
configuration.handednessPreference = handednessPreference.value configuration.handednessPreference = handednessPreference.value
configuration.cursorDragNavigationEnabled = cursorDragNavigationEnabled.value configuration.cursorDragNavigationEnabled = cursorDragNavigationEnabled.value
configuration.polishIntensity = polishIntensity.value configuration.polishIntensity = polishIntensity.value
configuration.llmThinkingEnabled = llmThinkingEnabled.value
configuration.flowSkipAppSwitch = flowSkipAppSwitch.value configuration.flowSkipAppSwitch = flowSkipAppSwitch.value
configuration.flowInactivityDuration = flowInactivityDuration.value configuration.flowInactivityDuration = flowInactivityDuration.value
} }
@@ -306,6 +319,7 @@ public extension SyncedAppSettingsV2 {
patch(&copy.handednessPreference, value: configuration.handednessPreference) patch(&copy.handednessPreference, value: configuration.handednessPreference)
patch(&copy.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled) patch(&copy.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled)
patch(&copy.polishIntensity, value: configuration.polishIntensity) patch(&copy.polishIntensity, value: configuration.polishIntensity)
patch(&copy.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
patch(&copy.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch) patch(&copy.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
patch(&copy.flowInactivityDuration, value: configuration.flowInactivityDuration) patch(&copy.flowInactivityDuration, value: configuration.flowInactivityDuration)
return copy return copy
@@ -335,6 +349,7 @@ public extension SyncedAppSettingsV2 {
touch(&copy.handednessPreference, value: configuration.handednessPreference) touch(&copy.handednessPreference, value: configuration.handednessPreference)
touch(&copy.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled) touch(&copy.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled)
touch(&copy.polishIntensity, value: configuration.polishIntensity) touch(&copy.polishIntensity, value: configuration.polishIntensity)
touch(&copy.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
touch(&copy.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch) touch(&copy.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
touch(&copy.flowInactivityDuration, value: configuration.flowInactivityDuration) touch(&copy.flowInactivityDuration, value: configuration.flowInactivityDuration)
return copy return copy
@@ -5,30 +5,78 @@
import Foundation 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 struct UsageStatisticsDeviceSlice: Codable, Equatable, Sendable {
public var updatedAt: Date public var updatedAt: Date
public var dictationDurationSeconds: TimeInterval public var dictationDurationSeconds: TimeInterval
public var dictationCharacterCount: Int public var dictationCharacterCount: Int
public var translationCharacterCount: 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( public init(
updatedAt: Date = Date(), updatedAt: Date = Date(),
dictationDurationSeconds: TimeInterval = 0, dictationDurationSeconds: TimeInterval = 0,
dictationCharacterCount: Int = 0, dictationCharacterCount: Int = 0,
translationCharacterCount: Int = 0 translationCharacterCount: Int = 0,
dailyDictationCharacters: [String: Int] = [:]
) { ) {
self.updatedAt = updatedAt self.updatedAt = updatedAt
self.dictationDurationSeconds = dictationDurationSeconds self.dictationDurationSeconds = dictationDurationSeconds
self.dictationCharacterCount = dictationCharacterCount self.dictationCharacterCount = dictationCharacterCount
self.translationCharacterCount = translationCharacterCount 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 { 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), updatedAt: max(local.updatedAt, remote.updatedAt),
dictationDurationSeconds: max(local.dictationDurationSeconds, remote.dictationDurationSeconds), dictationDurationSeconds: max(local.dictationDurationSeconds, remote.dictationDurationSeconds),
dictationCharacterCount: max(local.dictationCharacterCount, remote.dictationCharacterCount), 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 { public static func merge(local: SyncedUsageStatisticsV2, remote: SyncedUsageStatisticsV2) -> SyncedUsageStatisticsV2 {
var mergedDevices = local.devices var mergedDevices = local.devices
for (deviceID, remoteSlice) in remote.devices { for (deviceID, remoteSlice) in remote.devices {
@@ -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
}
}
@@ -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))
}
}
}
+7 -15
View File
@@ -66,6 +66,7 @@ public struct AppGroupStore: @unchecked Sendable {
public var handednessPreference: HandednessPreference { configuration.handednessPreference } public var handednessPreference: HandednessPreference { configuration.handednessPreference }
public var cursorDragNavigationEnabled: Bool { configuration.cursorDragNavigationEnabled } public var cursorDragNavigationEnabled: Bool { configuration.cursorDragNavigationEnabled }
public var polishIntensity: PolishIntensity { configuration.polishIntensity } public var polishIntensity: PolishIntensity { configuration.polishIntensity }
public var llmThinkingEnabled: Bool { configuration.llmThinkingEnabled }
public var isTranslationEffective: Bool { configuration.isTranslationEffective } public var isTranslationEffective: Bool { configuration.isTranslationEffective }
public var isLocalEngine: Bool { configuration.isLocalEngine } public var isLocalEngine: Bool { configuration.isLocalEngine }
public var polishModeForPipeline: PolishingService.PolishMode { configuration.polishModeForPipeline } public var polishModeForPipeline: PolishingService.PolishMode { configuration.polishModeForPipeline }
@@ -87,21 +88,7 @@ public struct AppGroupStore: @unchecked Sendable {
} }
public func setEngineMode(_ mode: String) { public func setEngineMode(_ mode: String) {
mutateConfiguration { config in mutateConfiguration { $0.engineMode = mode }
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)
}
}
AppGroupConfigDarwin.postConfigChanged() AppGroupConfigDarwin.postConfigChanged()
} }
@@ -134,6 +121,11 @@ public struct AppGroupStore: @unchecked Sendable {
mutateConfiguration { $0.polishIntensity = intensity } mutateConfiguration { $0.polishIntensity = intensity }
} }
public func setLLMThinkingEnabled(_ enabled: Bool) {
mutateConfiguration { $0.llmThinkingEnabled = enabled }
AppGroupConfigDarwin.postConfigChanged()
}
public func setLocalASRCustomLanguageModelEnabled(_ enabled: Bool) { public func setLocalASRCustomLanguageModelEnabled(_ enabled: Bool) {
mutateConfiguration { $0.localASRCustomLanguageModelEnabled = enabled } mutateConfiguration { $0.localASRCustomLanguageModelEnabled = enabled }
} }
@@ -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..<end), task: wsTask)
offset = end
}
// Let the server register the final frames before ending the task.
// Sending `finish-task` in the same instant as the last binary frame
// races the server's audio buffering (root cause of `emptyAudio` on
// very short clips).
try? await Task.sleep(nanoseconds: 120_000_000)
try await sendText(finishTaskMessage(taskID: taskID), task: wsTask)
return try await events.waitForFinalText(timeout: finalTimeout)
}
private func resolvedEndpointURL() throws -> 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<Void, Never>?
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()
}
}
@@ -13,6 +13,28 @@ public protocol CloudASRTranscribing: Sendable {
locale: Locale, locale: Locale,
dictionary: PersonalDictionary dictionary: PersonalDictionary
) async throws -> String ) 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 { public enum CloudASRClientFactory {
@@ -29,11 +51,12 @@ public enum CloudASRClientFactory {
model: asrModel, model: asrModel,
session: session session: session
) )
case .alibabaVocabulary: case .bailianStreaming:
return AlibabaFunASRClient( return BailianRealtimeASRClient(
apiKey: store.asrApiKey, apiKey: store.asrApiKey,
endpoint: store.asrBaseURL,
model: asrModel, model: asrModel,
persistence: store.cloudASRPersistence, vocabularyID: nil,
session: session session: session
) )
case .prompt: case .prompt:
@@ -44,6 +67,22 @@ public enum CloudASRClientFactory {
model: asrModel, model: asrModel,
session: session 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: case .localFallback:
return UnsupportedCloudASRClient(providerId: providerId) 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 struct AlibabaFunASRClient: CloudASRTranscribing {
/// actor-isolated cloud ASR path, same as the previous `AppGroupStore` holder.
struct AlibabaFunASRClient: CloudASRTranscribing, @unchecked Sendable {
let apiKey: String let apiKey: String
let model: String let model: String
let persistence: UserDefaults
let session: URLSession let session: URLSession
func prepare(dictionary: PersonalDictionary) async throws { func prepare(dictionary: PersonalDictionary) async throws {}
_ = try await AlibabaVocabularySync.ensureVocabularyID(
dictionary: dictionary,
apiKey: apiKey,
targetModel: CloudASRModelCatalog.alibabaVocabularyTargetModel,
defaults: persistence,
session: session
)
}
func transcribe( func transcribe(
samples: [Float], samples: [Float],
@@ -179,14 +207,6 @@ struct AlibabaFunASRClient: CloudASRTranscribing, @unchecked Sendable {
) async throws -> String { ) async throws -> String {
guard !apiKey.isEmpty else { throw CloudASRError.noAPIKey } 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 dataURI = PCMSampleWavEncoder.dataURI(samples: samples, sampleRate: sampleRate)
let urlString = CloudASRModelCatalog.alibabaAPIBase + CloudASRModelCatalog.alibabaMultimodalPath let urlString = CloudASRModelCatalog.alibabaAPIBase + CloudASRModelCatalog.alibabaMultimodalPath
guard let url = URL(string: urlString) else { throw CloudASRError.invalidURL } 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", "format": "wav",
"sample_rate": "\(sampleRate)", "sample_rate": "\(sampleRate)",
] ]
if let vocabularyID, !vocabularyID.isEmpty {
parameters["vocabulary_id"] = vocabularyID
}
let body: [String: Any] = [ let body: [String: Any] = [
"model": model, "model": model,
@@ -235,12 +252,12 @@ struct AlibabaFunASRClient: CloudASRTranscribing, @unchecked Sendable {
let (data, response) = try await session.data(for: request) let (data, response) = try await session.data(for: request)
try ZhipuCloudASRClient.validateHTTP(response: response, data: data) try ZhipuCloudASRClient.validateHTTP(response: response, data: data)
guard let text = Self.parseText(from: data)?.trimmingCharacters(in: .whitespacesAndNewlines), if let text = Self.parseText(from: data)?.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty else { !text.isEmpty {
throw CloudASRError.emptyTranscript
}
return text return text
} }
return ""
}
private static func parseText(from data: Data) -> String? { private static func parseText(from data: Data) -> String? {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
@@ -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 { struct PromptCloudASRClient: CloudASRTranscribing {
let providerId: String let providerId: String
@@ -264,6 +287,26 @@ struct PromptCloudASRClient: CloudASRTranscribing {
let apiKey: String let apiKey: String
let model: String let model: String
let session: URLSession 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 {} func prepare(dictionary: PersonalDictionary) async throws {}
@@ -281,6 +324,13 @@ struct PromptCloudASRClient: CloudASRTranscribing {
dictionary: dictionary dictionary: dictionary
) )
} }
if requestFormat == .openRouterJson {
return try await transcribeOpenRouterJSON(
samples: samples,
sampleRate: sampleRate,
dictionary: dictionary
)
}
return try await transcribeOpenAIStyle( return try await transcribeOpenAIStyle(
samples: samples, samples: samples,
sampleRate: sampleRate, 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( private func transcribeOpenAIStyle(
samples: [Float], samples: [Float],
sampleRate: Int, sampleRate: Int,
dictionary: PersonalDictionary dictionary: PersonalDictionary
) async throws -> String { ) 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 wav = PCMSampleWavEncoder.encode(samples: samples, sampleRate: sampleRate)
let trimmedBase = baseURL.hasSuffix("/") ? String(baseURL.dropLast()) : baseURL let trimmedBase = baseURL.hasSuffix("/") ? String(baseURL.dropLast()) : baseURL
let urlString = "\(trimmedBase)/audio/transcriptions" let urlString = "\(trimmedBase)/audio/transcriptions"
@@ -335,6 +395,45 @@ struct PromptCloudASRClient: CloudASRTranscribing {
return text 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( private func transcribeMiMo(
samples: [Float], samples: [Float],
sampleRate: Int, sampleRate: Int,
@@ -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()
}
}
@@ -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..<end),
sequence: Int32(sequence)
),
task: task
)
sequence += 1
offset = end
}
try await send(
VolcengineFrame.build(
messageType: .audioOnlyRequest,
flags: .negativeSequence,
serialization: .none,
payload: Data(),
sequence: -Int32(sequence)
),
task: task
)
let text = try await receiveFinalText(task: task)
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw CloudASRError.emptyTranscript }
return trimmed
}
private var resolvedResourceID: String {
resourceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? CloudASRModelCatalog.volcengineDefaultResourceID
: resourceID.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func resolvedEndpointURL() throws -> 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) }
}
}
@@ -93,7 +93,12 @@ public final class AppCloudSync {
let store = makeStore() let store = makeStore()
ICloudSyncPreferences.migrateLegacyTogglesIfNeeded(kvs: kvs, store: store) 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( ICloudSyncPreferences.cacheToAppGroup(
settingsEnabled: toggles.settings, settingsEnabled: toggles.settings,
dictionaryEnabled: toggles.dictionary, dictionaryEnabled: toggles.dictionary,
@@ -62,9 +62,10 @@ public final class SettingsCloudSync {
public func enableSync() async throws { public func enableSync() async throws {
let store = makeStore() let store = makeStore()
ICloudSyncPreferences.pushSettingsEnabled(true, kvs: kvs) ICloudSyncPreferences.pushSettingsEnabled(true, kvs: kvs)
ICloudSyncPreferences.pushDictionaryEnabled(true, kvs: kvs)
ICloudSyncPreferences.cacheToAppGroup( ICloudSyncPreferences.cacheToAppGroup(
settingsEnabled: true, settingsEnabled: true,
dictionaryEnabled: store.personalDictionaryICloudSyncEnabled, dictionaryEnabled: true,
store: store store: store
) )
@@ -93,7 +94,9 @@ public final class SettingsCloudSync {
public func disableSync() { public func disableSync() {
let store = makeStore() let store = makeStore()
ICloudSyncPreferences.pushSettingsEnabled(false, kvs: kvs) ICloudSyncPreferences.pushSettingsEnabled(false, kvs: kvs)
ICloudSyncPreferences.pushDictionaryEnabled(false, kvs: kvs)
store.setSettingsICloudSyncEnabled(false) store.setSettingsICloudSyncEnabled(false)
store.setPersonalDictionaryICloudSyncEnabled(false)
} }
public func pullAndMerge(store: AppGroupStore) async { public func pullAndMerge(store: AppGroupStore) async {
+16 -1
View File
@@ -107,8 +107,11 @@ public final class KeyboardState: ObservableObject {
/// Defaults to `offLocaleId` so the keyboard boots in the "off" /// Defaults to `offLocaleId` so the keyboard boots in the "off"
/// state on first install. /// state on first install.
@Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId @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 @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. /// Press-and-drag pads beside the mic for four-way caret movement.
@Published public var cursorDragNavigationEnabled: Bool = true @Published public var cursorDragNavigationEnabled: Bool = true
/// `true` while a cursor-drag pad is being pressed drives the hint /// `true` while a cursor-drag pad is being pressed drives the hint
@@ -151,6 +154,18 @@ public final class KeyboardState: ObservableObject {
case openSettings 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) // MARK: - Temporary Flow debug (remove after orange-mic investigation)
/// Mirrored from `KeyboardFlowCoordinator` for the on-screen debug panel. /// Mirrored from `KeyboardFlowCoordinator` for the on-screen debug panel.
+171 -4
View File
@@ -61,6 +61,8 @@ public struct OpenAICompatibleClient: LLMClient {
public let baseURL: String public let baseURL: String
public let apiKey: String public let apiKey: String
public let model: String public let model: String
public let providerId: String
public let thinkingEnabled: Bool
public let session: URLSession public let session: URLSession
/// Canonical request timeout for a single LLM HTTP round-trip. Both /// Canonical request timeout for a single LLM HTTP round-trip. Both
@@ -73,11 +75,15 @@ public struct OpenAICompatibleClient: LLMClient {
baseURL: String, baseURL: String,
apiKey: String, apiKey: String,
model: String, model: String,
providerId: String = "",
thinkingEnabled: Bool = false,
session: URLSession = .shared session: URLSession = .shared
) { ) {
self.baseURL = baseURL self.baseURL = baseURL
self.apiKey = apiKey self.apiKey = apiKey
self.model = model self.model = model
self.providerId = providerId
self.thinkingEnabled = thinkingEnabled
self.session = session self.session = session
} }
@@ -107,8 +113,13 @@ public struct OpenAICompatibleClient: LLMClient {
// the baseline when the caller does not supply one. // the baseline when the caller does not supply one.
req.timeoutInterval = timeout ?? requestTimeout req.timeoutInterval = timeout ?? requestTimeout
let encoder = JSONEncoder() req.httpBody = try Self.encodedBody(
req.httpBody = try encoder.encode(request) request,
providerId: providerId,
baseURL: baseURL,
model: model,
thinkingEnabled: thinkingEnabled
)
do { do {
let (data, response) = try await session.data(for: req) let (data, response) = try await session.data(for: req)
@@ -140,6 +151,27 @@ public struct OpenAICompatibleClient: LLMClient {
throw LLMError.transport(String(describing: error)) 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 // MARK: - Factory
@@ -147,13 +179,51 @@ public struct OpenAICompatibleClient: LLMClient {
public enum LLMClientFactory { public enum LLMClientFactory {
/// Build a client from the current `ProviderConfig`. /// Build a client from the current `ProviderConfig`.
public static func make(from config: ProviderConfig) -> LLMClient { public static func make(from config: ProviderConfig) -> LLMClient {
OpenAICompatibleClient( make(
providerId: config.providerId,
baseURL: config.baseURL, baseURL: config.baseURL,
apiKey: config.apiKey, 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 /// Single source of truth for the LLM request timeout, shared by
/// `LLMClient.requestTimeout` implementations and any caller that /// `LLMClient.requestTimeout` implementations and any caller that
/// wants to bound total time spent waiting on the LLM (e.g. /// wants to bound total time spent waiting on the LLM (e.g.
@@ -163,3 +233,100 @@ public enum LLMClientFactory {
OpenAICompatibleClient(baseURL: "", apiKey: "", model: "").requestTimeout 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")
}
}
@@ -170,7 +170,13 @@ public actor PolishingService {
} else { } else {
apiKey = store.apiKey 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 let prompt: String
@@ -350,7 +356,7 @@ public actor PolishingService {
private func shouldUseChineseGuidance(providerId: String) -> Bool { private func shouldUseChineseGuidance(providerId: String) -> Bool {
switch providerId { switch providerId {
case "zhipu", "moonshot", "qwen", "deepseek": case "zhipu", "moonshot", "qwen", "deepseek", "ark", "minimax", "siliconflow", "mimo":
return true return true
default: default:
return false return false
@@ -391,7 +397,7 @@ public actor PolishingService {
PreconfiguredKeys.isDeepseekConfigured { PreconfiguredKeys.isDeepseekConfigured {
return "deepseek" return "deepseek"
} }
return id == "deepseek" && store.engineMode == "cloud" ? "openai" : id return id
} }
internal static func hasPolishAPIKey(store: any ConfigurationStore, providerId: String) -> Bool { internal static func hasPolishAPIKey(store: any ConfigurationStore, providerId: String) -> Bool {
@@ -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<String>()
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]
}
}

Some files were not shown because too many files have changed in this diff Show More