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
@@ -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
|
||||||
|
|||||||
@@ -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 Paraformer;SenseVoice / 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 Paraformer;SenseVoice / Qwen3 0.6B / Qwen3 1.7B 分别标注速度最快 / 最平衡 / 质量最好(仍选 Paraformer 的用户迁移到 Qwen3 0.6B)。
|
||||||
- **macOS visual system**: full-app redesign around the brand line “Speak it. It’s typed.” / 「开口即文字。」— grouped sidebar, restrained accent selection, asymmetric Home stats (chars as hero), unified page headers, quieter status footer, and clearer dark-mode card elevation. / **macOS 视觉体系**:围绕品牌句「开口即文字。」/ “Speak it. It’s typed.” 做全 App 设计升级——侧栏分组、克制的选中态、首页不对称统计(字数主卡)、统一页头、降权状态栏,以及更清晰的暗色卡片层次。
|
- **macOS visual system**: full-app redesign around the brand line “Speak it. It’s typed.” / 「开口即文字。」— grouped sidebar, restrained accent selection, asymmetric Home stats (chars as hero), unified page headers, quieter status footer, and clearer dark-mode card elevation. / **macOS 视觉体系**:围绕品牌句「开口即文字。」/ “Speak it. It’s typed.” 做全 App 设计升级——侧栏分组、克制的选中态、首页不对称统计(字数主卡)、统一页头、降权状态栏,以及更清晰的暗色卡片层次。
|
||||||
@@ -19,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- **macOS page margins**: Home / History / Dictionary / Settings share `pageHorizontalInset` on titles and scroll *content*; ScrollViews / Forms stay full-bleed so the scrollbar sits on the window edge, while cards stay aligned with the page title. Settings keeps native grouped Form for control layout. / **macOS 页边距**:首页 / 历史 / 词库 / 设置在标题与滚动*内容*上共用 `pageHorizontalInset`;ScrollView / Form 通栏使滚动条贴窗口右缘,卡片仍与页标题对齐。设置保留原生分组 Form 以保证控件排版。
|
- **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 |
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "ark.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>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 |
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "groq.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>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 |
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "xai.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>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 |
@@ -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.
|
|
||||||
"""
|
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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. It’s 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 */
|
||||||
|
|||||||
@@ -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,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())
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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-...")) {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 大模型流式 ASR(WebSocket + 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 {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(©.handednessPreference, value: configuration.handednessPreference)
|
patch(©.handednessPreference, value: configuration.handednessPreference)
|
||||||
patch(©.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled)
|
patch(©.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled)
|
||||||
patch(©.polishIntensity, value: configuration.polishIntensity)
|
patch(©.polishIntensity, value: configuration.polishIntensity)
|
||||||
|
patch(©.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
|
||||||
patch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
|
patch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
|
||||||
patch(©.flowInactivityDuration, value: configuration.flowInactivityDuration)
|
patch(©.flowInactivityDuration, value: configuration.flowInactivityDuration)
|
||||||
return copy
|
return copy
|
||||||
@@ -335,6 +349,7 @@ public extension SyncedAppSettingsV2 {
|
|||||||
touch(©.handednessPreference, value: configuration.handednessPreference)
|
touch(©.handednessPreference, value: configuration.handednessPreference)
|
||||||
touch(©.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled)
|
touch(©.cursorDragNavigationEnabled, value: configuration.cursorDragNavigationEnabled)
|
||||||
touch(©.polishIntensity, value: configuration.polishIntensity)
|
touch(©.polishIntensity, value: configuration.polishIntensity)
|
||||||
|
touch(©.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
|
||||||
touch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
|
touch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
|
||||||
touch(©.flowInactivityDuration, value: configuration.flowInactivityDuration)
|
touch(©.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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
|
}
|
||||||