merge: flow-abcd host return, live activity, and session policy into main
This commit is contained in:
@@ -58,6 +58,9 @@ PreconfiguredKeys.local.swift
|
|||||||
# Lexicon build cache (downloaded SogouPopularDict TSV)
|
# Lexicon build cache (downloaded SogouPopularDict TSV)
|
||||||
.cache/
|
.cache/
|
||||||
|
|
||||||
|
# Custom LM compile output (Mac-only; device-side prepare uses .bin from bundle)
|
||||||
|
OSGKeyboard/Resources/CustomLanguageModel/v1/compiled/
|
||||||
|
|
||||||
# Python bytecode
|
# Python bytecode
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
@@ -7,7 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.4.1] - 2026-07-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- **Flow Live Activity**: Dynamic Island shows the OSGKeyboard brand mark during an active voice session (ActivityKit widget extension). / **Flow 灵动岛 Live Activity**:语音会话期间在灵动岛显示 OSGKeyboard 品牌标识(ActivityKit 小组件扩展)。
|
||||||
|
- **Xiaomi MiMo cloud provider**: preset for the cloud engine with `mimo-v2.5` polish via `api.xiaomimimo.com` (on-device ASR, same pipeline as other online providers). / **小米 MiMo 云端引擎**:云端引擎新增预设,经 `api.xiaomimimo.com` 使用 `mimo-v2.5` 润色(端侧 ASR,与其他在线服务相同管线)。
|
||||||
|
- **Flow session policy (A)**: on-demand session start from the keyboard; inactivity-based expiry (10m–24h, default 12h) reset after each utterance; handoff auto-starts recording. / **Flow 会话策略(A)**:键盘按需启动会话;无活动超时(10 分钟–24 小时,默认 12 小时)每句结束后重置;交接完成后自动开始录音。
|
||||||
|
- **Skip app switch (B+C)**: settings toggle (default on) plus cold-start overlay with swipe-back guidance and optional “Return to [App]” alert. / **跳过应用切换(B+C)**:设置开关(默认开)+ 冷启动极简页(右滑引导 + 可选「返回 [App]」弹窗)。
|
||||||
|
- **Host return whitelist (C+D)**: `HostAppURLRegistry` with 20 high-frequency host apps; `sourceApplication` capture on `startflow`. / **宿主回跳白名单(C+D)**:`HostAppURLRegistry` 覆盖 20 个高频宿主 App;`startflow` 时记录 `sourceApplication`。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Keyboard language label**: `PrimaryLanguage` set to `mis` so Settings no longer shows a misleading “English” subtitle under OSGKeyboard. / **键盘语言标签**:`PrimaryLanguage` 设为 `mis`,系统设置中 OSGKeyboard 下不再显示误导性的「英文」副标题。
|
||||||
|
- **Flow ASR pipelining**: shorter first chunk (2.5s) and 5s follow-ups so short utterances start on-device recognition while still recording; session-level ASR warmup and format cache reuse; live partials mirrored to the keyboard transcript line. / **Flow ASR 流水线**:首块 2.5 秒、后续 5 秒,短句录音期间即开始端侧识别;会话级 ASR 预热与格式缓存复用;实时 partial 同步到键盘转写行。
|
||||||
|
|
||||||
|
## [0.4.0] - 2026-07-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Custom ASR language model**: on-device `SFCustomLanguageModelData` bias model (computer/IT terms + curated AI/tech brands) prepared via `CustomLanguageModelManager` and applied to `DictationTranscriber` for Chinese dictation; compiled LM/Vocab shared through the App Group. / **自定义语音识别语言模型**:端侧 `SFCustomLanguageModelData` 偏置模型(计算机术语 + 精选 AI/科技品牌词),通过 `CustomLanguageModelManager` 在设备上准备并挂载到 `DictationTranscriber` 用于中文听写;编译后的 LM/Vocab 经 App Group 共享。
|
||||||
- **Cursor navigation**: keyboard drag pad (`CursorDragPad` / `CursorNavigation`) for precise caret movement. / **光标导航**:键盘拖动手势区(`CursorDragPad` / `CursorNavigation`),精确移动光标。
|
- **Cursor navigation**: keyboard drag pad (`CursorDragPad` / `CursorNavigation`) for precise caret movement. / **光标导航**:键盘拖动手势区(`CursorDragPad` / `CursorNavigation`),精确移动光标。
|
||||||
- **Key sound feedback**: `KeyboardSoundFeedback` plays system key clicks on input. / **按键音反馈**:`KeyboardSoundFeedback` 在输入时播放系统按键音。
|
- **Key sound feedback**: `KeyboardSoundFeedback` plays system key clicks on input. / **按键音反馈**:`KeyboardSoundFeedback` 在输入时播放系统按键音。
|
||||||
- **Personal dictionary tooling**: `DictionaryAliasGenerator` and `PersonalDictionaryEntrySheet` for managing custom terms and aliases. / **个人词库工具**:`DictionaryAliasGenerator` 与 `PersonalDictionaryEntrySheet`,用于管理自定义词条与别名。
|
- **Personal dictionary tooling**: `DictionaryAliasGenerator` and `PersonalDictionaryEntrySheet` for managing custom terms and aliases. / **个人词库工具**:`DictionaryAliasGenerator` 与 `PersonalDictionaryEntrySheet`,用于管理自定义词条与别名。
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "OSGLogo.svg",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
},
|
||||||
|
"properties" : {
|
||||||
|
"preserves-vector-representation" : true,
|
||||||
|
"template-rendering-intent" : "template"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M627.511 512.419C627.511 448.783 576.127 397.197 512.741 397.197C449.355 397.197 397.971 448.783 397.971 512.419C397.971 576.054 449.355 627.641 512.741 627.641V637.837C443.746 637.837 387.814 581.686 387.814 512.419C387.814 443.152 443.746 387 512.741 387C581.736 387 637.668 443.152 637.668 512.419C637.668 581.686 581.736 637.837 512.741 637.837V627.641C576.127 627.641 627.511 576.054 627.511 512.419Z" fill="white"/>
|
||||||
|
<path d="M56.2632 512.419C56.2632 581.686 112.195 637.837 181.19 637.837C250.185 637.837 306.117 581.686 306.117 512.419C306.117 443.152 250.185 387 181.19 387C112.195 387 56.2632 443.152 56.2632 512.419Z" fill="white"/>
|
||||||
|
<path d="M399.618 562.094L399.618 552.257L512.74 552.257L512.74 562.094L399.618 562.094Z" fill="white"/>
|
||||||
|
<path d="M512.741 483.398V473.561H625.864V483.398H512.741Z" fill="white"/>
|
||||||
|
<path d="M843.308 387.981C910.987 387.981 966.093 441.8 968.171 508.975H843.308V518.812H968.096C965.014 585.066 910.324 637.836 843.308 637.836C774.313 637.836 718.381 581.904 718.381 512.909C718.381 443.914 774.312 387.981 843.308 387.981ZM968.234 518.812H968.096C968.187 516.855 968.234 514.888 968.234 512.909C968.234 511.593 968.211 510.281 968.171 508.975H968.234V518.812Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "mimo.png",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
},
|
||||||
|
"properties" : {
|
||||||
|
"template-rendering-intent" : "template"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
@@ -40,6 +40,54 @@
|
|||||||
<false/>
|
<false/>
|
||||||
<key>LSApplicationCategoryType</key>
|
<key>LSApplicationCategoryType</key>
|
||||||
<string>public.app-category.utilities</string>
|
<string>public.app-category.utilities</string>
|
||||||
|
<key>LSApplicationQueriesSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>weixin</string>
|
||||||
|
<string>mqq</string>
|
||||||
|
<string>wxwork</string>
|
||||||
|
<string>dingtalk</string>
|
||||||
|
<string>lark</string>
|
||||||
|
<string>tg</string>
|
||||||
|
<string>whatsapp</string>
|
||||||
|
<string>line</string>
|
||||||
|
<string>fb-messenger</string>
|
||||||
|
<string>slack</string>
|
||||||
|
<string>msteams</string>
|
||||||
|
<string>discord</string>
|
||||||
|
<string>notion</string>
|
||||||
|
<string>bear</string>
|
||||||
|
<string>obsidian</string>
|
||||||
|
<string>drafts5</string>
|
||||||
|
<string>googlegmail</string>
|
||||||
|
<string>ms-outlook</string>
|
||||||
|
<string>googlechrome</string>
|
||||||
|
<string>sinaweibo</string>
|
||||||
|
<string>xhsdiscover</string>
|
||||||
|
<string>sgnl</string>
|
||||||
|
<string>kakaotalk</string>
|
||||||
|
<string>viber</string>
|
||||||
|
<string>zalo</string>
|
||||||
|
<string>skype</string>
|
||||||
|
<string>zoomus</string>
|
||||||
|
<string>things</string>
|
||||||
|
<string>todoist</string>
|
||||||
|
<string>evernote</string>
|
||||||
|
<string>onenote</string>
|
||||||
|
<string>readdle-spark</string>
|
||||||
|
<string>firefox</string>
|
||||||
|
<string>microsoft-edge</string>
|
||||||
|
<string>fb</string>
|
||||||
|
<string>instagram</string>
|
||||||
|
<string>twitter</string>
|
||||||
|
<string>barcelona</string>
|
||||||
|
<string>snapchat</string>
|
||||||
|
<string>reddit</string>
|
||||||
|
<string>pinterest</string>
|
||||||
|
<string>zhihu</string>
|
||||||
|
<string>bilibili</string>
|
||||||
|
<string>snssdk1128</string>
|
||||||
|
<string>tiktok</string>
|
||||||
|
</array>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
@@ -49,6 +97,8 @@
|
|||||||
<string>OSGKeyboard uses the microphone for voice dictation and keeps a background audio session active while a voice session is running.</string>
|
<string>OSGKeyboard uses the microphone for voice dictation and keeps a background audio session active while a voice session is running.</string>
|
||||||
<key>NSSpeechRecognitionUsageDescription</key>
|
<key>NSSpeechRecognitionUsageDescription</key>
|
||||||
<string>OSGKeyboard uses on-device speech recognition to transcribe your voice. Audio is processed on your device and is not uploaded for transcription.</string>
|
<string>OSGKeyboard uses on-device speech recognition to transcribe your voice. Audio is processed on your device and is not uploaded for transcription.</string>
|
||||||
|
<key>NSSupportsLiveActivities</key>
|
||||||
|
<true/>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|||||||
@@ -6,76 +6,26 @@ import OSGKeyboardShared
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct OSGKeyboardApp: App {
|
struct OSGKeyboardApp: App {
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@UIApplicationDelegateAdaptor(AppURLHandler.self) private var appURLHandler
|
||||||
|
|
||||||
@StateObject private var config = ProviderConfig.shared
|
|
||||||
@StateObject private var dictationCoordinator = DictationSessionCoordinator()
|
|
||||||
@StateObject private var flowManager = FlowSessionManager()
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
MaterialIconsFont.registerIfNeeded()
|
MaterialIconsFont.registerIfNeeded()
|
||||||
// v0.2.0: no backend-specific ASR provider to install here.
|
if AppGroup.isAvailable {
|
||||||
// The local engine uses iOS 26 `SpeechAnalyzer` +
|
CustomLanguageModelManager.shared.prepareInBackgroundIfNeeded()
|
||||||
// `DictationTranscriber`, which the shared framework wires
|
}
|
||||||
// up directly via `ASRServiceFactory.make(...)`. The previous
|
|
||||||
// Qwen3 CoreML backend (and its mlx-swift transitive
|
|
||||||
// dependency) was removed in this release.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ThemedRoot {
|
if AppGroup.isAvailable {
|
||||||
if AppGroup.isAvailable {
|
ThemedRoot {
|
||||||
if config.hasCompletedOnboarding {
|
MainAppRoot()
|
||||||
MainTabView()
|
}
|
||||||
} else {
|
} else {
|
||||||
OnboardingView(config: config)
|
ThemedRoot {
|
||||||
}
|
|
||||||
} else {
|
|
||||||
AppGroupErrorView()
|
AppGroupErrorView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.environment(\.locale, config.uiLanguage.swiftUILocale)
|
|
||||||
.environmentObject(flowManager)
|
|
||||||
.onAppear {
|
|
||||||
FlowAppLifecycle.shared.setForeground(scenePhase == .active)
|
|
||||||
flowManager.setAppForeground(scenePhase == .active)
|
|
||||||
}
|
|
||||||
.onOpenURL { url in
|
|
||||||
guard url.scheme == "osgkeyboard" else { return }
|
|
||||||
switch url.host {
|
|
||||||
case "dictate":
|
|
||||||
dictationCoordinator.present()
|
|
||||||
case "startflow":
|
|
||||||
flowManager.startSession()
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.fullScreenCover(isPresented: $dictationCoordinator.isPresenting) {
|
|
||||||
DictationCaptureView(
|
|
||||||
config: config,
|
|
||||||
coordinator: dictationCoordinator
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.onChange(of: config.hasCompletedOnboarding) { _, done in
|
|
||||||
if done {
|
|
||||||
flowManager.autoStartIfNeeded()
|
|
||||||
// v0.2.0: no on-device ASR weights to warm up.
|
|
||||||
// iOS `SpeechAnalyzer` is always ready.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: scenePhase) { _, phase in
|
|
||||||
flowManager.handleScenePhase(phase)
|
|
||||||
guard phase == .active, AppGroup.isAvailable, config.hasCompletedOnboarding else { return }
|
|
||||||
// v0.2.0: iOS `SpeechAnalyzer` is bundled with the OS
|
|
||||||
// and needs no warm-up after a background trip.
|
|
||||||
if flowManager.isActive {
|
|
||||||
flowManager.extendSession()
|
|
||||||
} else {
|
|
||||||
flowManager.autoStartIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
{
|
{
|
||||||
"version": "v1",
|
"version": "v1",
|
||||||
"name": "ai-tech-brands",
|
"name": "ai-tech-brands",
|
||||||
"generated_at": "2026-07-05T08:00:01.226941+00:00",
|
"generated_at": "2026-07-05T11:34:08.462301+00:00",
|
||||||
"locale": "zh-Hans",
|
"locale": "zh-Hans",
|
||||||
"entry_count": 749,
|
"entry_count": 1259,
|
||||||
"seed_file": "Scripts/lexicon/seeds/ai_tech_brands_seed.tsv",
|
"seed_file": "Scripts/lexicon/seeds/ai_tech_brands_seed.tsv",
|
||||||
"license": "MIT (curated seed; OSGKeyboard contributors)",
|
"license": "MIT (curated seed; OSGKeyboard contributors)",
|
||||||
"categories": {
|
"categories": {
|
||||||
"ai_brand": 133,
|
"ai_brand": 169,
|
||||||
"ai_model": 31,
|
"ai_model": 90,
|
||||||
"ai_platform": 20,
|
"ai_platform": 53,
|
||||||
"ai_term": 116,
|
"ai_term": 197,
|
||||||
"dev_tool": 74,
|
"dev_tool": 283,
|
||||||
"fintech": 7,
|
"fintech": 7,
|
||||||
"tech_company": 280,
|
"tech_company": 314,
|
||||||
"tech_leader": 37,
|
"tech_leader": 37,
|
||||||
"tech_term": 51
|
"tech_term": 109
|
||||||
},
|
},
|
||||||
"notes": [
|
"notes": [
|
||||||
"Curated bilingual AI brands, tech companies, terminology, and hot words.",
|
"Curated bilingual AI brands, tech companies, terminology, and hot words.",
|
||||||
|
|||||||
@@ -18,10 +18,22 @@ open ai ai_tech_seed ai_brand 100 OpenAI
|
|||||||
Open AI ai_tech_seed ai_brand 100 OpenAI
|
Open AI ai_tech_seed ai_brand 100 OpenAI
|
||||||
OpenAI ai_tech_seed ai_brand 100 OpenAI
|
OpenAI ai_tech_seed ai_brand 100 OpenAI
|
||||||
深度求索 shen du qiu suo ai_tech_seed ai_brand 100 深度求索
|
深度求索 shen du qiu suo ai_tech_seed ai_brand 100 深度求索
|
||||||
|
ChatGPT 5 ai_tech_seed ai_model 98 GPT-5
|
||||||
|
gpt five ai_tech_seed ai_model 98 GPT-5
|
||||||
|
GPT five ai_tech_seed ai_model 98 GPT-5
|
||||||
|
gpt five five ai_tech_seed ai_model 98 GPT-5.5
|
||||||
|
GPT five point five ai_tech_seed ai_model 98 GPT-5.5
|
||||||
|
GPT-5 ai_tech_seed ai_model 98 GPT-5
|
||||||
|
GPT-5.5 ai_tech_seed ai_model 98 GPT-5.5
|
||||||
|
gpt-5.5 ai_tech_seed ai_model 98 GPT-5.5
|
||||||
NVDA ai_tech_seed tech_company 98 Nvidia
|
NVDA ai_tech_seed tech_company 98 Nvidia
|
||||||
Nvidia ai_tech_seed tech_company 98 Nvidia
|
Nvidia ai_tech_seed tech_company 98 Nvidia
|
||||||
nvidia ai_tech_seed tech_company 98 Nvidia
|
nvidia ai_tech_seed tech_company 98 Nvidia
|
||||||
英伟达 ai_tech_seed tech_company 98 Nvidia
|
英伟达 ai_tech_seed tech_company 98 Nvidia
|
||||||
|
Claude Code ai_tech_seed dev_tool 96 Claude Code
|
||||||
|
claude code ai_tech_seed dev_tool 96 Claude Code
|
||||||
|
ClaudeCode ai_tech_seed dev_tool 96 Claude Code
|
||||||
|
克劳德代码 ai_tech_seed dev_tool 96 Claude Code
|
||||||
Alibaba ai_tech_seed tech_company 95 Alibaba
|
Alibaba ai_tech_seed tech_company 95 Alibaba
|
||||||
alibaba ai_tech_seed tech_company 95 Alibaba
|
alibaba ai_tech_seed tech_company 95 Alibaba
|
||||||
Alphabet ai_tech_seed tech_company 95 Google
|
Alphabet ai_tech_seed tech_company 95 Google
|
||||||
@@ -36,6 +48,9 @@ byd ai_tech_seed tech_company 95 BYD
|
|||||||
byte dance ai_tech_seed tech_company 95 ByteDance
|
byte dance ai_tech_seed tech_company 95 ByteDance
|
||||||
ByteDance ai_tech_seed tech_company 95 ByteDance
|
ByteDance ai_tech_seed tech_company 95 ByteDance
|
||||||
Bytedance ai_tech_seed tech_company 95 ByteDance
|
Bytedance ai_tech_seed tech_company 95 ByteDance
|
||||||
|
deepseek r one ai_tech_seed ai_model 95 DeepSeek R1
|
||||||
|
DeepSeek R1 ai_tech_seed ai_model 95 DeepSeek R1
|
||||||
|
DeepSeek-R1 ai_tech_seed ai_model 95 DeepSeek R1
|
||||||
Facebook ai_tech_seed tech_company 95 Meta
|
Facebook ai_tech_seed tech_company 95 Meta
|
||||||
Gemini ai_tech_seed ai_brand 95 Gemini
|
Gemini ai_tech_seed ai_brand 95 Gemini
|
||||||
gemini ai_tech_seed ai_brand 95 Gemini
|
gemini ai_tech_seed ai_brand 95 Gemini
|
||||||
@@ -100,8 +115,18 @@ xiaomi ai_tech_seed tech_company 95 Xiaomi
|
|||||||
通义千问 ai_tech_seed ai_brand 95 Qwen
|
通义千问 ai_tech_seed ai_brand 95 Qwen
|
||||||
阿里 ai_tech_seed tech_company 95 Alibaba
|
阿里 ai_tech_seed tech_company 95 Alibaba
|
||||||
阿里巴巴 ai_tech_seed tech_company 95 Alibaba
|
阿里巴巴 ai_tech_seed tech_company 95 Alibaba
|
||||||
|
claude opus ai_tech_seed ai_model 94 Claude Opus
|
||||||
|
claude sonnet ai_tech_seed ai_model 94 Claude Sonnet
|
||||||
|
Opus ai_tech_seed ai_model 94 Claude Opus
|
||||||
|
Opus 4 ai_tech_seed ai_model 94 Claude Opus
|
||||||
|
Opus 4.7 ai_tech_seed ai_model 94 Claude Opus
|
||||||
|
Sonnet ai_tech_seed ai_model 94 Claude Sonnet
|
||||||
|
Sonnet 4 ai_tech_seed ai_model 94 Claude Sonnet
|
||||||
|
Sonnet 4.6 ai_tech_seed ai_model 94 Claude Sonnet
|
||||||
agent ai_tech_seed ai_term 92 智能体
|
agent ai_tech_seed ai_term 92 智能体
|
||||||
Agent ai_tech_seed ai_term 92 智能体
|
Agent ai_tech_seed ai_term 92 智能体
|
||||||
|
Agent Mode ai_tech_seed ai_term 92 Agent Mode
|
||||||
|
agent mode ai_tech_seed ai_term 92 Agent Mode
|
||||||
AI agent ai_tech_seed ai_term 92 智能体
|
AI agent ai_tech_seed ai_term 92 智能体
|
||||||
Amazon Web Services ai_tech_seed tech_company 92 AWS
|
Amazon Web Services ai_tech_seed tech_company 92 AWS
|
||||||
aws ai_tech_seed tech_company 92 AWS
|
aws ai_tech_seed tech_company 92 AWS
|
||||||
@@ -109,13 +134,34 @@ Baidu ai_tech_seed tech_company 92 Baidu
|
|||||||
baidu ai_tech_seed tech_company 92 Baidu
|
baidu ai_tech_seed tech_company 92 Baidu
|
||||||
CATL ai_tech_seed tech_company 92 CATL
|
CATL ai_tech_seed tech_company 92 CATL
|
||||||
catl ai_tech_seed tech_company 92 CATL
|
catl ai_tech_seed tech_company 92 CATL
|
||||||
|
Codex ai_tech_seed dev_tool 92 OpenAI Codex
|
||||||
|
codex cli ai_tech_seed dev_tool 92 OpenAI Codex
|
||||||
|
Composer ai_tech_seed dev_tool 92 Composer
|
||||||
|
Composer 2 ai_tech_seed dev_tool 92 Composer
|
||||||
|
Composer 2.5 ai_tech_seed dev_tool 92 Composer
|
||||||
|
context engineering ai_tech_seed ai_term 92 context engineering
|
||||||
|
cursor composer ai_tech_seed dev_tool 92 Composer
|
||||||
|
Cursor Composer ai_tech_seed dev_tool 92 Cursor Composer
|
||||||
deepseek r1 ai_tech_seed ai_model 92 DeepSeek-R1
|
deepseek r1 ai_tech_seed ai_model 92 DeepSeek-R1
|
||||||
DeepSeek R1 ai_tech_seed ai_model 92 DeepSeek-R1
|
deepseek v four ai_tech_seed ai_model 92 DeepSeek V4
|
||||||
DeepSeek-R1 ai_tech_seed ai_model 92 DeepSeek-R1
|
deepseek v three ai_tech_seed ai_model 92 DeepSeek V3
|
||||||
|
DeepSeek V3 ai_tech_seed ai_model 92 DeepSeek V3
|
||||||
|
DeepSeek V4 ai_tech_seed ai_model 92 DeepSeek V4
|
||||||
|
DeepSeek V4 Pro ai_tech_seed ai_model 92 DeepSeek V4
|
||||||
|
DeepSeek-V3 ai_tech_seed ai_model 92 DeepSeek V3
|
||||||
|
DeepSeek-V4 ai_tech_seed ai_model 92 DeepSeek V4
|
||||||
|
dictation transcriber ai_tech_seed dev_tool 92 DictationTranscriber
|
||||||
|
Dictation Transcriber ai_tech_seed dev_tool 92 DictationTranscriber
|
||||||
|
DictationTranscriber ai_tech_seed dev_tool 92 DictationTranscriber
|
||||||
Elon Musk ai_tech_seed tech_leader 92 Elon Musk
|
Elon Musk ai_tech_seed tech_leader 92 Elon Musk
|
||||||
elon musk ai_tech_seed tech_leader 92 Elon Musk
|
elon musk ai_tech_seed tech_leader 92 Elon Musk
|
||||||
|
Gemini 3 Pro ai_tech_seed ai_model 92 Gemini 3 Pro
|
||||||
|
Gemini Pro ai_tech_seed ai_model 92 Gemini 3 Pro
|
||||||
|
gemini three pro ai_tech_seed ai_model 92 Gemini 3 Pro
|
||||||
GitHub ai_tech_seed tech_company 92 GitHub
|
GitHub ai_tech_seed tech_company 92 GitHub
|
||||||
github ai_tech_seed tech_company 92 GitHub
|
github ai_tech_seed tech_company 92 GitHub
|
||||||
|
gpt four o ai_tech_seed ai_model 92 GPT-4o
|
||||||
|
GPT four oh ai_tech_seed ai_model 92 GPT-4o
|
||||||
large language model ai_tech_seed ai_term 92 LLM
|
large language model ai_tech_seed ai_term 92 LLM
|
||||||
large model ai_tech_seed ai_term 92 大模型
|
large model ai_tech_seed ai_term 92 大模型
|
||||||
LLM ai_tech_seed ai_term 92 LLM
|
LLM ai_tech_seed ai_term 92 LLM
|
||||||
@@ -123,14 +169,21 @@ llm ai_tech_seed ai_term 92 LLM
|
|||||||
MCP ai_tech_seed ai_term 92 MCP
|
MCP ai_tech_seed ai_term 92 MCP
|
||||||
MCP server ai_tech_seed ai_term 92 MCP
|
MCP server ai_tech_seed ai_term 92 MCP
|
||||||
model context protocol ai_tech_seed ai_term 92 MCP
|
model context protocol ai_tech_seed ai_term 92 MCP
|
||||||
|
OpenAI Codex ai_tech_seed dev_tool 92 OpenAI Codex
|
||||||
|
openai codex ai_tech_seed dev_tool 92 OpenAI Codex
|
||||||
prompt ai_tech_seed ai_term 92 提示词
|
prompt ai_tech_seed ai_term 92 提示词
|
||||||
Prompt ai_tech_seed ai_term 92 提示词
|
Prompt ai_tech_seed ai_term 92 提示词
|
||||||
R1 ai_tech_seed ai_model 92 DeepSeek-R1
|
R1 ai_tech_seed ai_model 92 DeepSeek-R1
|
||||||
|
speech analyzer ai_tech_seed dev_tool 92 SpeechAnalyzer
|
||||||
|
Speech Analyzer ai_tech_seed dev_tool 92 SpeechAnalyzer
|
||||||
|
SpeechAnalyzer ai_tech_seed dev_tool 92 SpeechAnalyzer
|
||||||
tik tok ai_tech_seed tech_company 92 TikTok
|
tik tok ai_tech_seed tech_company 92 TikTok
|
||||||
Tik Tok ai_tech_seed tech_company 92 TikTok
|
Tik Tok ai_tech_seed tech_company 92 TikTok
|
||||||
TikTok ai_tech_seed tech_company 92 TikTok
|
TikTok ai_tech_seed tech_company 92 TikTok
|
||||||
we chat ai_tech_seed tech_company 92 WeChat
|
we chat ai_tech_seed tech_company 92 WeChat
|
||||||
WeChat ai_tech_seed tech_company 92 WeChat
|
WeChat ai_tech_seed tech_company 92 WeChat
|
||||||
|
上下文工程 ai_tech_seed ai_term 92 context engineering
|
||||||
|
代理模式 ai_tech_seed ai_term 92 Agent Mode
|
||||||
大模型 da mo xing ai_tech_seed ai_term 92 大模型
|
大模型 da mo xing ai_tech_seed ai_term 92 大模型
|
||||||
大语言模型 ai_tech_seed ai_term 92 LLM
|
大语言模型 ai_tech_seed ai_term 92 LLM
|
||||||
宁德时代 ai_tech_seed tech_company 92 CATL
|
宁德时代 ai_tech_seed tech_company 92 CATL
|
||||||
@@ -138,20 +191,21 @@ WeChat ai_tech_seed tech_company 92 WeChat
|
|||||||
抖音海外 ai_tech_seed tech_company 92 TikTok
|
抖音海外 ai_tech_seed tech_company 92 TikTok
|
||||||
提示词 ti shi ci ai_tech_seed ai_term 92 提示词
|
提示词 ti shi ci ai_tech_seed ai_term 92 提示词
|
||||||
智能体 zhi neng ti ai_tech_seed ai_term 92 智能体
|
智能体 zhi neng ti ai_tech_seed ai_term 92 智能体
|
||||||
|
智能体模式 ai_tech_seed ai_term 92 Agent Mode
|
||||||
百度 ai_tech_seed tech_company 92 Baidu
|
百度 ai_tech_seed tech_company 92 Baidu
|
||||||
马斯克 ai_tech_seed tech_leader 92 Elon Musk
|
马斯克 ai_tech_seed tech_leader 92 Elon Musk
|
||||||
agentic ai_tech_seed ai_term 90 agent
|
agentic ai_tech_seed ai_term 90 agent
|
||||||
AGI ai_tech_seed ai_term 90 AGI
|
AGI ai_tech_seed ai_term 90 AGI
|
||||||
agi ai_tech_seed ai_term 90 AGI
|
agi ai_tech_seed ai_term 90 AGI
|
||||||
alphabet ai_tech_seed tech_company 90 Alphabet
|
alphabet ai_tech_seed tech_company 90 Alphabet
|
||||||
|
Codex CLI ai_tech_seed dev_tool 90 Codex CLI
|
||||||
Copilot ai_tech_seed ai_brand 90 Copilot
|
Copilot ai_tech_seed ai_brand 90 Copilot
|
||||||
copilot ai_tech_seed ai_brand 90 Copilot
|
copilot ai_tech_seed ai_brand 90 Copilot
|
||||||
Cursor ai_tech_seed dev_tool 90 Cursor
|
Cursor ai_tech_seed dev_tool 90 Cursor
|
||||||
cursor ai ai_tech_seed dev_tool 90 Cursor
|
cursor ai ai_tech_seed dev_tool 90 Cursor
|
||||||
Cursor AI ai_tech_seed dev_tool 90 Cursor
|
Cursor AI ai_tech_seed dev_tool 90 Cursor
|
||||||
|
custom language model data ai_tech_seed dev_tool 90 SFCustomLanguageModelData
|
||||||
deepseek v3 ai_tech_seed ai_model 90 DeepSeek-V3
|
deepseek v3 ai_tech_seed ai_model 90 DeepSeek-V3
|
||||||
DeepSeek V3 ai_tech_seed ai_model 90 DeepSeek-V3
|
|
||||||
DeepSeek-V3 ai_tech_seed ai_model 90 DeepSeek-V3
|
|
||||||
DJI ai_tech_seed tech_company 90 DJI
|
DJI ai_tech_seed tech_company 90 DJI
|
||||||
dji ai_tech_seed tech_company 90 DJI
|
dji ai_tech_seed tech_company 90 DJI
|
||||||
dou yin ai_tech_seed tech_company 90 Douyin
|
dou yin ai_tech_seed tech_company 90 Douyin
|
||||||
@@ -164,6 +218,8 @@ ERNIE ai_tech_seed ai_brand 90 文心一言
|
|||||||
Ernie ai_tech_seed ai_brand 90 文心一言
|
Ernie ai_tech_seed ai_brand 90 文心一言
|
||||||
fine tuning ai_tech_seed ai_term 90 微调
|
fine tuning ai_tech_seed ai_term 90 微调
|
||||||
fine-tuning ai_tech_seed ai_term 90 微调
|
fine-tuning ai_tech_seed ai_term 90 微调
|
||||||
|
Gemini 2.5 Pro ai_tech_seed ai_model 90 Gemini 2.5 Pro
|
||||||
|
gemini two five pro ai_tech_seed ai_model 90 Gemini 2.5 Pro
|
||||||
GitHub Copilot ai_tech_seed ai_brand 90 Copilot
|
GitHub Copilot ai_tech_seed ai_brand 90 Copilot
|
||||||
GLM ai_tech_seed ai_brand 90 GLM
|
GLM ai_tech_seed ai_brand 90 GLM
|
||||||
glm ai_tech_seed ai_brand 90 GLM
|
glm ai_tech_seed ai_brand 90 GLM
|
||||||
@@ -173,6 +229,10 @@ Jensen Huang ai_tech_seed tech_leader 90 Jensen Huang
|
|||||||
jensen huang ai_tech_seed tech_leader 90 Jensen Huang
|
jensen huang ai_tech_seed tech_leader 90 Jensen Huang
|
||||||
k8s ai_tech_seed dev_tool 90 Kubernetes
|
k8s ai_tech_seed dev_tool 90 Kubernetes
|
||||||
K8s ai_tech_seed dev_tool 90 Kubernetes
|
K8s ai_tech_seed dev_tool 90 Kubernetes
|
||||||
|
kimi k two ai_tech_seed ai_model 90 Kimi K2
|
||||||
|
Kimi K2 ai_tech_seed ai_model 90 Kimi K2
|
||||||
|
Kimi K2.5 ai_tech_seed ai_model 90 Kimi K2
|
||||||
|
Kimi K2.6 ai_tech_seed ai_model 90 Kimi K2
|
||||||
Kubernetes ai_tech_seed dev_tool 90 Kubernetes
|
Kubernetes ai_tech_seed dev_tool 90 Kubernetes
|
||||||
kubernetes ai_tech_seed dev_tool 90 Kubernetes
|
kubernetes ai_tech_seed dev_tool 90 Kubernetes
|
||||||
Lei Jun ai_tech_seed tech_leader 90 雷军
|
Lei Jun ai_tech_seed tech_leader 90 雷军
|
||||||
@@ -186,12 +246,21 @@ Llama 3 ai_tech_seed ai_model 90 Llama
|
|||||||
Llama 4 ai_tech_seed ai_model 90 Llama
|
Llama 4 ai_tech_seed ai_model 90 Llama
|
||||||
LoRA ai_tech_seed ai_term 90 LoRA
|
LoRA ai_tech_seed ai_term 90 LoRA
|
||||||
lora ai_tech_seed ai_term 90 LoRA
|
lora ai_tech_seed ai_term 90 LoRA
|
||||||
|
mcp server ai_tech_seed ai_term 90 MCP server
|
||||||
Microsoft Copilot ai_tech_seed ai_brand 90 Copilot
|
Microsoft Copilot ai_tech_seed ai_brand 90 Copilot
|
||||||
|
multi agent ai_tech_seed ai_term 90 multi-agent
|
||||||
|
multi-agent ai_tech_seed ai_term 90 multi-agent
|
||||||
NIO ai_tech_seed tech_company 90 NIO
|
NIO ai_tech_seed tech_company 90 NIO
|
||||||
nio ai_tech_seed tech_company 90 NIO
|
nio ai_tech_seed tech_company 90 NIO
|
||||||
prompt engineering ai_tech_seed ai_term 90 prompt
|
prompt engineering ai_tech_seed ai_term 90 prompt
|
||||||
|
Qwen Coder ai_tech_seed ai_model 90 Qwen Coder
|
||||||
|
qwen coder ai_tech_seed ai_model 90 Qwen Coder
|
||||||
|
SF Custom Language Model Data ai_tech_seed dev_tool 90 SFCustomLanguageModelData
|
||||||
|
SFCustomLanguageModelData ai_tech_seed dev_tool 90 SFCustomLanguageModelData
|
||||||
Steve Jobs ai_tech_seed tech_leader 90 乔布斯
|
Steve Jobs ai_tech_seed tech_leader 90 乔布斯
|
||||||
steve jobs ai_tech_seed tech_leader 90 Steve Jobs
|
steve jobs ai_tech_seed tech_leader 90 Steve Jobs
|
||||||
|
tool calling ai_tech_seed ai_term 90 tool calling
|
||||||
|
tool-calling ai_tech_seed ai_term 90 tool calling
|
||||||
TSMC ai_tech_seed tech_company 90 TSMC
|
TSMC ai_tech_seed tech_company 90 TSMC
|
||||||
tsmc ai_tech_seed tech_company 90 TSMC
|
tsmc ai_tech_seed tech_company 90 TSMC
|
||||||
X Peng ai_tech_seed tech_company 90 XPeng
|
X Peng ai_tech_seed tech_company 90 XPeng
|
||||||
@@ -200,19 +269,24 @@ xpeng ai_tech_seed tech_company 90 XPeng
|
|||||||
Zhipu ai_tech_seed ai_brand 90 GLM
|
Zhipu ai_tech_seed ai_brand 90 GLM
|
||||||
乔布斯 qiao bu si ai_tech_seed tech_leader 90 乔布斯
|
乔布斯 qiao bu si ai_tech_seed tech_leader 90 乔布斯
|
||||||
低秩适配 ai_tech_seed ai_term 90 LoRA
|
低秩适配 ai_tech_seed ai_term 90 LoRA
|
||||||
|
千问 coder ai_tech_seed ai_model 90 Qwen Coder
|
||||||
台积电 ai_tech_seed tech_company 90 TSMC
|
台积电 ai_tech_seed tech_company 90 TSMC
|
||||||
|
多智能体 ai_tech_seed ai_term 90 multi-agent
|
||||||
大疆 ai_tech_seed tech_company 90 DJI
|
大疆 ai_tech_seed tech_company 90 DJI
|
||||||
小鹏汽车 ai_tech_seed tech_company 90 XPeng
|
小鹏汽车 ai_tech_seed tech_company 90 XPeng
|
||||||
嵌入 ai_tech_seed ai_term 90 embedding
|
嵌入 ai_tech_seed ai_term 90 embedding
|
||||||
|
工具调用 ai_tech_seed ai_term 90 tool calling
|
||||||
微调 wei tiao ai_tech_seed ai_term 90 微调
|
微调 wei tiao ai_tech_seed ai_term 90 微调
|
||||||
抖音 ai_tech_seed tech_company 90 Douyin
|
抖音 ai_tech_seed tech_company 90 Douyin
|
||||||
文心一言 wen xin yi yan ai_tech_seed ai_brand 90 文心一言
|
文心一言 wen xin yi yan ai_tech_seed ai_brand 90 文心一言
|
||||||
智谱 ai_tech_seed ai_brand 90 GLM
|
智谱 ai_tech_seed ai_brand 90 GLM
|
||||||
月之暗面 yue zhi an mian ai_tech_seed ai_brand 90 月之暗面
|
月之暗面 yue zhi an mian ai_tech_seed ai_brand 90 月之暗面
|
||||||
|
模型上下文协议服务器 ai_tech_seed ai_term 90 MCP server
|
||||||
理想 ai_tech_seed tech_company 90 Li Auto
|
理想 ai_tech_seed tech_company 90 Li Auto
|
||||||
理想汽车 ai_tech_seed tech_company 90 Li Auto
|
理想汽车 ai_tech_seed tech_company 90 Li Auto
|
||||||
蔚来 ai_tech_seed tech_company 90 NIO
|
蔚来 ai_tech_seed tech_company 90 NIO
|
||||||
豆包 dou bao ai_tech_seed ai_brand 90 豆包
|
豆包 dou bao ai_tech_seed ai_brand 90 豆包
|
||||||
|
通义千问 coder ai_tech_seed ai_model 90 Qwen Coder
|
||||||
通用人工智能 ai_tech_seed ai_term 90 AGI
|
通用人工智能 ai_tech_seed ai_term 90 AGI
|
||||||
雷军 lei jun ai_tech_seed tech_leader 90 雷军
|
雷军 lei jun ai_tech_seed tech_leader 90 雷军
|
||||||
黄仁勋 ai_tech_seed tech_leader 90 Jensen Huang
|
黄仁勋 ai_tech_seed tech_leader 90 Jensen Huang
|
||||||
@@ -230,14 +304,26 @@ bilibili ai_tech_seed tech_company 88 Bilibili
|
|||||||
B站 ai_tech_seed tech_company 88 Bilibili
|
B站 ai_tech_seed tech_company 88 Bilibili
|
||||||
chain of thought ai_tech_seed ai_term 88 chain of thought
|
chain of thought ai_tech_seed ai_term 88 chain of thought
|
||||||
chain-of-thought ai_tech_seed ai_term 88 chain of thought
|
chain-of-thought ai_tech_seed ai_term 88 chain of thought
|
||||||
|
Cognition Devin ai_tech_seed dev_tool 88 Devin
|
||||||
context window ai_tech_seed ai_term 88 上下文窗口
|
context window ai_tech_seed ai_term 88 上下文窗口
|
||||||
|
Core ML ai_tech_seed dev_tool 88 Core ML
|
||||||
|
core ml ai_tech_seed dev_tool 88 Core ML
|
||||||
|
CoreML ai_tech_seed dev_tool 88 Core ML
|
||||||
deep mind ai_tech_seed ai_brand 88 DeepMind
|
deep mind ai_tech_seed ai_brand 88 DeepMind
|
||||||
DeepMind ai_tech_seed ai_brand 88 DeepMind
|
DeepMind ai_tech_seed ai_brand 88 DeepMind
|
||||||
|
Devin ai_tech_seed dev_tool 88 Devin
|
||||||
|
devin ai_tech_seed dev_tool 88 Devin
|
||||||
Docker ai_tech_seed tech_company 88 Docker
|
Docker ai_tech_seed tech_company 88 Docker
|
||||||
docker ai_tech_seed tech_company 88 Docker
|
docker ai_tech_seed tech_company 88 Docker
|
||||||
dou bao ai_tech_seed ai_brand 88 Doubao
|
dou bao ai_tech_seed ai_brand 88 Doubao
|
||||||
ernie ai_tech_seed ai_brand 88 ERNIE
|
ernie ai_tech_seed ai_brand 88 ERNIE
|
||||||
function calling ai_tech_seed ai_term 88 function calling
|
function calling ai_tech_seed ai_term 88 function calling
|
||||||
|
Gemini 2.5 Flash ai_tech_seed ai_model 88 Gemini Flash
|
||||||
|
Gemini 3 Flash ai_tech_seed ai_model 88 Gemini Flash
|
||||||
|
Gemini CLI ai_tech_seed dev_tool 88 Gemini CLI
|
||||||
|
gemini cli ai_tech_seed dev_tool 88 Gemini CLI
|
||||||
|
Gemini Flash ai_tech_seed ai_model 88 Gemini Flash
|
||||||
|
gemini flash ai_tech_seed ai_model 88 Gemini Flash
|
||||||
gen ai ai_tech_seed ai_term 88 GenAI
|
gen ai ai_tech_seed ai_term 88 GenAI
|
||||||
GenAI ai_tech_seed ai_term 88 GenAI
|
GenAI ai_tech_seed ai_term 88 GenAI
|
||||||
github copilot ai_tech_seed ai_brand 88 GitHub Copilot
|
github copilot ai_tech_seed ai_brand 88 GitHub Copilot
|
||||||
@@ -254,6 +340,8 @@ intel ai_tech_seed tech_company 88 Intel
|
|||||||
JD ai_tech_seed tech_company 88 JD.com
|
JD ai_tech_seed tech_company 88 JD.com
|
||||||
JD.com ai_tech_seed tech_company 88 JD.com
|
JD.com ai_tech_seed tech_company 88 JD.com
|
||||||
jd.com ai_tech_seed tech_company 88 JD.com
|
jd.com ai_tech_seed tech_company 88 JD.com
|
||||||
|
Kimi Code ai_tech_seed dev_tool 88 Kimi Code
|
||||||
|
kimi code ai_tech_seed dev_tool 88 Kimi Code
|
||||||
Lenovo ai_tech_seed tech_company 88 Lenovo
|
Lenovo ai_tech_seed tech_company 88 Lenovo
|
||||||
lenovo ai_tech_seed tech_company 88 Lenovo
|
lenovo ai_tech_seed tech_company 88 Lenovo
|
||||||
Meituan ai_tech_seed tech_company 88 Meituan
|
Meituan ai_tech_seed tech_company 88 Meituan
|
||||||
@@ -264,41 +352,60 @@ Midjourney ai_tech_seed ai_brand 88 Midjourney
|
|||||||
mixture of experts ai_tech_seed ai_term 88 MoE
|
mixture of experts ai_tech_seed ai_term 88 MoE
|
||||||
MoE ai_tech_seed ai_term 88 MoE
|
MoE ai_tech_seed ai_term 88 MoE
|
||||||
moe ai_tech_seed ai_term 88 MoE
|
moe ai_tech_seed ai_term 88 MoE
|
||||||
|
Moonshot Kimi Code ai_tech_seed dev_tool 88 Kimi Code
|
||||||
multimodal ai_tech_seed ai_term 88 多模态
|
multimodal ai_tech_seed ai_term 88 多模态
|
||||||
o1 ai_tech_seed ai_model 88 o1
|
o1 ai_tech_seed ai_model 88 o1
|
||||||
O1 ai_tech_seed ai_model 88 o1
|
O1 ai_tech_seed ai_model 88 o1
|
||||||
o3 ai_tech_seed ai_model 88 o3
|
o3 ai_tech_seed ai_model 88 o3
|
||||||
O3 ai_tech_seed ai_model 88 o3
|
O3 ai_tech_seed ai_model 88 o3
|
||||||
|
open code ai_tech_seed dev_tool 88 OpenCode
|
||||||
openai o1 ai_tech_seed ai_model 88 o1
|
openai o1 ai_tech_seed ai_model 88 o1
|
||||||
openai o3 ai_tech_seed ai_model 88 o3
|
openai o3 ai_tech_seed ai_model 88 o3
|
||||||
|
OpenCode ai_tech_seed dev_tool 88 OpenCode
|
||||||
|
opencode ai_tech_seed dev_tool 88 OpenCode
|
||||||
|
OpenCode AI ai_tech_seed dev_tool 88 OpenCode
|
||||||
PDD ai_tech_seed tech_company 88 Pinduoduo
|
PDD ai_tech_seed tech_company 88 Pinduoduo
|
||||||
Perplexity ai_tech_seed ai_brand 88 Perplexity
|
Perplexity ai_tech_seed ai_brand 88 Perplexity
|
||||||
perplexity ai ai_tech_seed ai_brand 88 Perplexity
|
perplexity ai ai_tech_seed ai_brand 88 Perplexity
|
||||||
Perplexity AI ai_tech_seed ai_brand 88 Perplexity
|
Perplexity AI ai_tech_seed ai_brand 88 Perplexity
|
||||||
Pinduoduo ai_tech_seed tech_company 88 Pinduoduo
|
Pinduoduo ai_tech_seed tech_company 88 Pinduoduo
|
||||||
pinduoduo ai_tech_seed tech_company 88 Pinduoduo
|
pinduoduo ai_tech_seed tech_company 88 Pinduoduo
|
||||||
|
postgre sql ai_tech_seed tech_term 88 Postgres
|
||||||
|
Postgres ai_tech_seed tech_term 88 Postgres
|
||||||
|
postgres ai_tech_seed tech_term 88 Postgres
|
||||||
|
PostgreSQL ai_tech_seed tech_term 88 Postgres
|
||||||
|
postgresql ai_tech_seed tech_term 88 PostgreSQL
|
||||||
Py Torch ai_tech_seed dev_tool 88 PyTorch
|
Py Torch ai_tech_seed dev_tool 88 PyTorch
|
||||||
Python ai_tech_seed dev_tool 88 Python
|
Python ai_tech_seed dev_tool 88 Python
|
||||||
python ai_tech_seed dev_tool 88 Python
|
python ai_tech_seed dev_tool 88 Python
|
||||||
PyTorch ai_tech_seed dev_tool 88 PyTorch
|
PyTorch ai_tech_seed dev_tool 88 PyTorch
|
||||||
pytorch ai_tech_seed dev_tool 88 PyTorch
|
pytorch ai_tech_seed dev_tool 88 PyTorch
|
||||||
|
Qwen Max ai_tech_seed ai_model 88 Qwen Max
|
||||||
|
qwen max ai_tech_seed ai_model 88 Qwen Max
|
||||||
|
Qwen3 Max ai_tech_seed ai_model 88 Qwen Max
|
||||||
reasoning ai_tech_seed ai_term 88 推理
|
reasoning ai_tech_seed ai_term 88 推理
|
||||||
reasoning model ai_tech_seed ai_term 88 reasoning model
|
reasoning model ai_tech_seed ai_term 88 reasoning model
|
||||||
Sam Altman ai_tech_seed tech_leader 88 Sam Altman
|
Sam Altman ai_tech_seed tech_leader 88 Sam Altman
|
||||||
sam altman ai_tech_seed tech_leader 88 Sam Altman
|
sam altman ai_tech_seed tech_leader 88 Sam Altman
|
||||||
Samsung ai_tech_seed tech_company 88 Samsung
|
Samsung ai_tech_seed tech_company 88 Samsung
|
||||||
samsung ai_tech_seed tech_company 88 Samsung
|
samsung ai_tech_seed tech_company 88 Samsung
|
||||||
|
SF Speech Language Model ai_tech_seed dev_tool 88 SFSpeechLanguageModel
|
||||||
|
SFSpeechLanguageModel ai_tech_seed dev_tool 88 SFSpeechLanguageModel
|
||||||
SMIC ai_tech_seed tech_company 88 SMIC
|
SMIC ai_tech_seed tech_company 88 SMIC
|
||||||
smic ai_tech_seed tech_company 88 SMIC
|
smic ai_tech_seed tech_company 88 SMIC
|
||||||
Sora ai_tech_seed ai_brand 88 Sora
|
Sora ai_tech_seed ai_brand 88 Sora
|
||||||
sora ai ai_tech_seed ai_brand 88 Sora
|
sora ai ai_tech_seed ai_brand 88 Sora
|
||||||
Sora AI ai_tech_seed ai_brand 88 Sora
|
Sora AI ai_tech_seed ai_brand 88 Sora
|
||||||
|
speech language model ai_tech_seed dev_tool 88 SFSpeechLanguageModel
|
||||||
Stripe ai_tech_seed tech_company 88 Stripe
|
Stripe ai_tech_seed tech_company 88 Stripe
|
||||||
stripe ai_tech_seed tech_company 88 Stripe
|
stripe ai_tech_seed tech_company 88 Stripe
|
||||||
|
swift data ai_tech_seed dev_tool 88 SwiftData
|
||||||
|
SwiftData ai_tech_seed dev_tool 88 SwiftData
|
||||||
Taobao ai_tech_seed tech_company 88 Taobao
|
Taobao ai_tech_seed tech_company 88 Taobao
|
||||||
taobao ai_tech_seed tech_company 88 Taobao
|
taobao ai_tech_seed tech_company 88 Taobao
|
||||||
thinking model ai_tech_seed ai_term 88 reasoning model
|
thinking model ai_tech_seed ai_term 88 reasoning model
|
||||||
tool calling ai_tech_seed ai_term 88 function calling
|
tool use ai_tech_seed ai_term 88 tool use
|
||||||
|
tool-use ai_tech_seed ai_term 88 tool use
|
||||||
Visual Studio Code ai_tech_seed dev_tool 88 VS Code
|
Visual Studio Code ai_tech_seed dev_tool 88 VS Code
|
||||||
VS Code ai_tech_seed dev_tool 88 VS Code
|
VS Code ai_tech_seed dev_tool 88 VS Code
|
||||||
vs code ai_tech_seed dev_tool 88 VS Code
|
vs code ai_tech_seed dev_tool 88 VS Code
|
||||||
@@ -315,7 +422,7 @@ Zhipu AI ai_tech_seed ai_brand 88 智谱
|
|||||||
哔哩哔哩 ai_tech_seed tech_company 88 Bilibili
|
哔哩哔哩 ai_tech_seed tech_company 88 Bilibili
|
||||||
多模态 duo mo tai ai_tech_seed ai_term 88 多模态
|
多模态 duo mo tai ai_tech_seed ai_term 88 多模态
|
||||||
山姆奥特曼 ai_tech_seed tech_leader 88 Sam Altman
|
山姆奥特曼 ai_tech_seed tech_leader 88 Sam Altman
|
||||||
工具调用 ai_tech_seed ai_term 88 function calling
|
工具使用 ai_tech_seed ai_term 88 tool use
|
||||||
幻觉 huan jue ai_tech_seed ai_term 88 幻觉
|
幻觉 huan jue ai_tech_seed ai_term 88 幻觉
|
||||||
思维链 ai_tech_seed ai_term 88 chain of thought
|
思维链 ai_tech_seed ai_term 88 chain of thought
|
||||||
拼多多 ai_tech_seed tech_company 88 Pinduoduo
|
拼多多 ai_tech_seed tech_company 88 Pinduoduo
|
||||||
@@ -330,6 +437,58 @@ Zhipu AI ai_tech_seed ai_brand 88 智谱
|
|||||||
联想 ai_tech_seed tech_company 88 Lenovo
|
联想 ai_tech_seed tech_company 88 Lenovo
|
||||||
英特尔 ai_tech_seed tech_company 88 Intel
|
英特尔 ai_tech_seed tech_company 88 Intel
|
||||||
蚂蚁集团 ai_tech_seed tech_company 88 Ant Group
|
蚂蚁集团 ai_tech_seed tech_company 88 Ant Group
|
||||||
|
通义千问 Max ai_tech_seed ai_model 88 Qwen Max
|
||||||
|
App Group ai_tech_seed dev_tool 86 App Group
|
||||||
|
app group ai_tech_seed dev_tool 86 App Group
|
||||||
|
App Groups ai_tech_seed dev_tool 86 App Group
|
||||||
|
evals ai_tech_seed ai_term 86 evals
|
||||||
|
evaluation ai_tech_seed ai_term 86 evals
|
||||||
|
GLM 5 ai_tech_seed ai_model 86 GLM-5
|
||||||
|
glm five ai_tech_seed ai_model 86 GLM-5
|
||||||
|
Grok 4 ai_tech_seed ai_model 86 Grok 4
|
||||||
|
Grok 4.3 ai_tech_seed ai_model 86 Grok 4
|
||||||
|
Grok Build ai_tech_seed ai_model 86 Grok 4
|
||||||
|
grok four ai_tech_seed ai_model 86 Grok 4
|
||||||
|
humanoid ai_tech_seed tech_term 86 人形机器人
|
||||||
|
humanoid robot ai_tech_seed tech_term 86 人形机器人
|
||||||
|
Kling ai_tech_seed ai_brand 86 可灵
|
||||||
|
Kling AI ai_tech_seed ai_brand 86 可灵
|
||||||
|
kling ai ai_tech_seed ai_brand 86 Kling
|
||||||
|
llama four ai_tech_seed ai_model 86 Llama 4
|
||||||
|
Llama Maverick ai_tech_seed ai_model 86 Llama 4
|
||||||
|
Llama Scout ai_tech_seed ai_model 86 Llama 4
|
||||||
|
Manus ai_tech_seed ai_brand 86 Manus
|
||||||
|
manus ai_tech_seed ai_brand 86 Manus
|
||||||
|
Manus AI ai_tech_seed ai_brand 86 Manus
|
||||||
|
MCP client ai_tech_seed ai_term 86 MCP client
|
||||||
|
mcp client ai_tech_seed ai_term 86 MCP client
|
||||||
|
Qwen Thinking ai_tech_seed ai_model 86 Qwen Thinking
|
||||||
|
qwen thinking ai_tech_seed ai_model 86 Qwen Thinking
|
||||||
|
RedNote ai_tech_seed tech_company 86 小红书
|
||||||
|
speech transcriber ai_tech_seed dev_tool 86 SpeechTranscriber
|
||||||
|
Speech Transcriber ai_tech_seed dev_tool 86 SpeechTranscriber
|
||||||
|
SpeechTranscriber ai_tech_seed dev_tool 86 SpeechTranscriber
|
||||||
|
structured output ai_tech_seed ai_term 86 structured output
|
||||||
|
structured outputs ai_tech_seed ai_term 86 structured output
|
||||||
|
sub agent ai_tech_seed ai_term 86 subagent
|
||||||
|
subagent ai_tech_seed ai_term 86 subagent
|
||||||
|
Swift Testing ai_tech_seed dev_tool 86 Swift Testing
|
||||||
|
swift testing ai_tech_seed dev_tool 86 Swift Testing
|
||||||
|
Testing framework ai_tech_seed dev_tool 86 Swift Testing
|
||||||
|
v zero ai_tech_seed dev_tool 86 v0
|
||||||
|
v0 ai_tech_seed dev_tool 86 v0
|
||||||
|
V0 ai_tech_seed dev_tool 86 v0
|
||||||
|
vercel v0 ai_tech_seed dev_tool 86 v0
|
||||||
|
Xiaohongshu ai_tech_seed tech_company 86 小红书
|
||||||
|
人形机器人 ren xing ji qi ren ai_tech_seed tech_term 86 人形机器人
|
||||||
|
可灵 ke ling ai_tech_seed ai_brand 86 可灵
|
||||||
|
子智能体 ai_tech_seed ai_term 86 subagent
|
||||||
|
小红书 xiao hong shu ai_tech_seed tech_company 86 小红书
|
||||||
|
模型上下文协议客户端 ai_tech_seed ai_term 86 MCP client
|
||||||
|
模型评测 ai_tech_seed ai_term 86 evals
|
||||||
|
结构化输出 ai_tech_seed ai_term 86 structured output
|
||||||
|
评测集 ai_tech_seed ai_term 86 evals
|
||||||
|
通义千问 thinking ai_tech_seed ai_model 86 Qwen Thinking
|
||||||
Adobe ai_tech_seed tech_company 85 Adobe
|
Adobe ai_tech_seed tech_company 85 Adobe
|
||||||
adobe ai_tech_seed tech_company 85 Adobe
|
adobe ai_tech_seed tech_company 85 Adobe
|
||||||
API ai_tech_seed tech_term 85 API
|
API ai_tech_seed tech_term 85 API
|
||||||
@@ -340,8 +499,6 @@ ARM Holdings ai_tech_seed tech_company 85 Arm
|
|||||||
ASML ai_tech_seed tech_company 85 ASML
|
ASML ai_tech_seed tech_company 85 ASML
|
||||||
asml ai_tech_seed tech_company 85 ASML
|
asml ai_tech_seed tech_company 85 ASML
|
||||||
autonomous driving ai_tech_seed tech_term 85 自动驾驶
|
autonomous driving ai_tech_seed tech_term 85 自动驾驶
|
||||||
claude opus ai_tech_seed ai_model 85 Opus
|
|
||||||
claude sonnet ai_tech_seed ai_model 85 Sonnet
|
|
||||||
CUDA ai_tech_seed tech_term 85 CUDA
|
CUDA ai_tech_seed tech_term 85 CUDA
|
||||||
cuda ai_tech_seed tech_term 85 CUDA
|
cuda ai_tech_seed tech_term 85 CUDA
|
||||||
DALL E ai_tech_seed ai_brand 85 DALL-E
|
DALL E ai_tech_seed ai_brand 85 DALL-E
|
||||||
@@ -389,8 +546,6 @@ NextJS ai_tech_seed dev_tool 85 Next.js
|
|||||||
Oppo ai_tech_seed tech_company 85 Oppo
|
Oppo ai_tech_seed tech_company 85 Oppo
|
||||||
oppo ai_tech_seed tech_company 85 Oppo
|
oppo ai_tech_seed tech_company 85 Oppo
|
||||||
OPPO ai_tech_seed tech_company 85 Oppo
|
OPPO ai_tech_seed tech_company 85 Oppo
|
||||||
Opus ai_tech_seed ai_model 85 Opus
|
|
||||||
Opus 4 ai_tech_seed ai_model 85 Opus
|
|
||||||
Qualcomm ai_tech_seed tech_company 85 Qualcomm
|
Qualcomm ai_tech_seed tech_company 85 Qualcomm
|
||||||
qualcomm ai_tech_seed tech_company 85 Qualcomm
|
qualcomm ai_tech_seed tech_company 85 Qualcomm
|
||||||
quantization ai_tech_seed ai_term 85 量化
|
quantization ai_tech_seed ai_term 85 量化
|
||||||
@@ -405,8 +560,6 @@ SD ai_tech_seed ai_brand 85 Stable Diffusion
|
|||||||
Shein ai_tech_seed tech_company 85 Shein
|
Shein ai_tech_seed tech_company 85 Shein
|
||||||
shein ai_tech_seed tech_company 85 Shein
|
shein ai_tech_seed tech_company 85 Shein
|
||||||
SHEIN ai_tech_seed tech_company 85 Shein
|
SHEIN ai_tech_seed tech_company 85 Shein
|
||||||
Sonnet ai_tech_seed ai_model 85 Sonnet
|
|
||||||
Sonnet 4 ai_tech_seed ai_model 85 Sonnet
|
|
||||||
Sony ai_tech_seed tech_company 85 Sony
|
Sony ai_tech_seed tech_company 85 Sony
|
||||||
sony ai_tech_seed tech_company 85 Sony
|
sony ai_tech_seed tech_company 85 Sony
|
||||||
Spark ai_tech_seed ai_brand 85 星火
|
Spark ai_tech_seed ai_brand 85 星火
|
||||||
@@ -464,24 +617,158 @@ zeekr ai_tech_seed tech_company 85 Zeekr
|
|||||||
讯飞星火 xing huo ai_tech_seed ai_brand 85 星火
|
讯飞星火 xing huo ai_tech_seed ai_brand 85 星火
|
||||||
量化 liang hua ai_tech_seed ai_term 85 量化
|
量化 liang hua ai_tech_seed ai_term 85 量化
|
||||||
高通 ai_tech_seed tech_company 85 Qualcomm
|
高通 ai_tech_seed tech_company 85 Qualcomm
|
||||||
|
AI governance ai_tech_seed tech_term 84 AI治理
|
||||||
|
AI治理 AI zhi li ai_tech_seed tech_term 84 AI治理
|
||||||
|
Amazon Kiro ai_tech_seed dev_tool 84 Kiro
|
||||||
|
Amazon Q ai_tech_seed dev_tool 84 Amazon Q
|
||||||
|
amazon q ai_tech_seed dev_tool 84 Amazon Q
|
||||||
|
App Intents ai_tech_seed dev_tool 84 App Intents
|
||||||
|
app intents ai_tech_seed dev_tool 84 App Intents
|
||||||
|
App Store Connect ai_tech_seed dev_tool 84 App Store Connect
|
||||||
|
app store connect ai_tech_seed dev_tool 84 App Store Connect
|
||||||
|
AppIntents ai_tech_seed dev_tool 84 App Intents
|
||||||
|
AppStoreConnect ai_tech_seed dev_tool 84 App Store Connect
|
||||||
|
asset inventory ai_tech_seed dev_tool 84 AssetInventory
|
||||||
|
AssetInventory ai_tech_seed dev_tool 84 AssetInventory
|
||||||
|
av audio engine ai_tech_seed dev_tool 84 AVAudioEngine
|
||||||
|
av audio session ai_tech_seed dev_tool 84 AVAudioSession
|
||||||
|
AVAudioEngine ai_tech_seed dev_tool 84 AVAudioEngine
|
||||||
|
AVAudioSession ai_tech_seed dev_tool 84 AVAudioSession
|
||||||
|
AWS Q ai_tech_seed dev_tool 84 Amazon Q
|
||||||
|
Bolt AI ai_tech_seed dev_tool 84 Bolt.new
|
||||||
|
bolt new ai_tech_seed dev_tool 84 Bolt.new
|
||||||
|
Bolt.new ai_tech_seed dev_tool 84 Bolt.new
|
||||||
|
cap cut ai_tech_seed tech_company 84 CapCut
|
||||||
|
CapCut ai_tech_seed tech_company 84 剪映
|
||||||
|
GLM 4.6 ai_tech_seed ai_model 84 GLM-4.6
|
||||||
|
GLM Coding Plan ai_tech_seed dev_tool 84 GLM Coding Plan
|
||||||
|
glm coding plan ai_tech_seed dev_tool 84 GLM Coding Plan
|
||||||
|
glm four six ai_tech_seed ai_model 84 GLM-4.6
|
||||||
|
GLM-4.6 ai_tech_seed ai_model 84 GLM-4.6
|
||||||
|
grok build ai_tech_seed dev_tool 84 Grok Build
|
||||||
|
Grok Build CLI ai_tech_seed dev_tool 84 Grok Build
|
||||||
|
Hailuo ai_tech_seed ai_brand 84 海螺AI
|
||||||
|
Hangzhou Six Little Dragons ai_tech_seed tech_term 84 杭州六小龙
|
||||||
|
Jimeng ai_tech_seed ai_brand 84 即梦
|
||||||
|
Kiro ai_tech_seed dev_tool 84 Kiro
|
||||||
|
kiro ai_tech_seed dev_tool 84 Kiro
|
||||||
|
lang graph ai_tech_seed ai_platform 84 LangGraph
|
||||||
|
LangGraph ai_tech_seed ai_platform 84 LangGraph
|
||||||
|
Llama 4 Maverick ai_tech_seed ai_model 84 Llama Maverick
|
||||||
|
llama maverick ai_tech_seed ai_model 84 Llama Maverick
|
||||||
|
local model ai_tech_seed ai_term 84 本地模型
|
||||||
|
Lovable ai_tech_seed dev_tool 84 Lovable
|
||||||
|
lovable ai_tech_seed dev_tool 84 Lovable
|
||||||
|
Lovable AI ai_tech_seed dev_tool 84 Lovable
|
||||||
|
MiniMax Video ai_tech_seed ai_brand 84 海螺AI
|
||||||
|
Mistral Large ai_tech_seed ai_model 84 Mistral Large
|
||||||
|
mistral large ai_tech_seed ai_model 84 Mistral Large
|
||||||
|
Mistral Large 3 ai_tech_seed ai_model 84 Mistral Large
|
||||||
|
Neon ai_tech_seed tech_company 84 Neon
|
||||||
|
neon database ai_tech_seed tech_company 84 Neon
|
||||||
|
Neon Postgres ai_tech_seed tech_company 84 Neon
|
||||||
|
on-device model ai_tech_seed ai_term 84 端侧模型
|
||||||
|
open hands ai_tech_seed dev_tool 84 OpenHands
|
||||||
|
open telemetry ai_tech_seed tech_term 84 OpenTelemetry
|
||||||
|
OpenHands ai_tech_seed dev_tool 84 OpenHands
|
||||||
|
OpenHands AI ai_tech_seed dev_tool 84 OpenHands
|
||||||
|
OpenTelemetry ai_tech_seed tech_term 84 OpenTelemetry
|
||||||
|
OTel ai_tech_seed tech_term 84 OpenTelemetry
|
||||||
|
Prisma ai_tech_seed dev_tool 84 Prisma
|
||||||
|
prisma orm ai_tech_seed dev_tool 84 Prisma
|
||||||
|
Prisma ORM ai_tech_seed dev_tool 84 Prisma
|
||||||
|
prompt cache ai_tech_seed ai_term 84 prompt caching
|
||||||
|
prompt caching ai_tech_seed ai_term 84 prompt caching
|
||||||
|
Q Developer ai_tech_seed dev_tool 84 Amazon Q
|
||||||
|
Replit Agent ai_tech_seed dev_tool 84 Replit Agent
|
||||||
|
replit agent ai_tech_seed dev_tool 84 Replit Agent
|
||||||
|
Replit AI ai_tech_seed dev_tool 84 Replit Agent
|
||||||
|
semantic retrieval ai_tech_seed ai_term 84 semantic search
|
||||||
|
semantic search ai_tech_seed ai_term 84 semantic search
|
||||||
|
shad cn ai_tech_seed dev_tool 84 shadcn/ui
|
||||||
|
shadcn ai_tech_seed dev_tool 84 shadcn/ui
|
||||||
|
shadcn ui ai_tech_seed dev_tool 84 shadcn/ui
|
||||||
|
shadcn/ui ai_tech_seed dev_tool 84 shadcn/ui
|
||||||
|
Speech AssetInventory ai_tech_seed dev_tool 84 AssetInventory
|
||||||
|
SQLite ai_tech_seed tech_term 84 SQLite
|
||||||
|
sqlite ai_tech_seed tech_term 84 SQLite
|
||||||
|
StackBlitz Bolt ai_tech_seed dev_tool 84 Bolt.new
|
||||||
|
Tailwind ai_tech_seed dev_tool 84 Tailwind CSS
|
||||||
|
Tailwind CSS ai_tech_seed dev_tool 84 Tailwind CSS
|
||||||
|
tailwind css ai_tech_seed dev_tool 84 Tailwind CSS
|
||||||
|
test flight ai_tech_seed dev_tool 84 TestFlight
|
||||||
|
TestFlight ai_tech_seed dev_tool 84 TestFlight
|
||||||
|
Trae ai_tech_seed dev_tool 84 Trae
|
||||||
|
trae ai_tech_seed dev_tool 84 Trae
|
||||||
|
Trae AI ai_tech_seed dev_tool 84 Trae
|
||||||
|
Trae CN ai_tech_seed dev_tool 84 Trae
|
||||||
|
vector retrieval ai_tech_seed ai_term 84 vector search
|
||||||
|
vector search ai_tech_seed ai_term 84 vector search
|
||||||
|
world model ai_tech_seed ai_term 84 世界模型
|
||||||
|
xiao hong shu ai_tech_seed tech_company 84 Xiaohongshu
|
||||||
|
Yuanbao ai_tech_seed ai_brand 84 腾讯元宝
|
||||||
|
世界模型 shi jie mo xing ai_tech_seed ai_term 84 世界模型
|
||||||
|
剪映 jian ying ai_tech_seed tech_company 84 剪映
|
||||||
|
即梦 ji meng ai_tech_seed ai_brand 84 即梦
|
||||||
|
即梦AI ji meng ai_tech_seed ai_brand 84 即梦
|
||||||
|
向量搜索 ai_tech_seed ai_term 84 vector search
|
||||||
|
提示词缓存 ai_tech_seed ai_term 84 prompt caching
|
||||||
|
智谱 coding plan ai_tech_seed dev_tool 84 GLM Coding Plan
|
||||||
|
本地模型 ben di mo xing ai_tech_seed ai_term 84 本地模型
|
||||||
|
杭州六小龙 hang zhou liu xiao long ai_tech_seed tech_term 84 杭州六小龙
|
||||||
|
海螺AI hai luo AI ai_tech_seed ai_brand 84 海螺AI
|
||||||
|
端侧模型 duan ce mo xing ai_tech_seed ai_term 84 端侧模型
|
||||||
|
腾讯元宝 teng xun yuan bao ai_tech_seed ai_brand 84 腾讯元宝
|
||||||
|
语义搜索 ai_tech_seed ai_term 84 semantic search
|
||||||
01.AI ai_tech_seed ai_brand 82 零一万物
|
01.AI ai_tech_seed ai_brand 82 零一万物
|
||||||
|
activity kit ai_tech_seed dev_tool 82 ActivityKit
|
||||||
|
ActivityKit ai_tech_seed dev_tool 82 ActivityKit
|
||||||
AI native ai_tech_seed ai_term 82 AI native
|
AI native ai_tech_seed ai_term 82 AI native
|
||||||
|
AI video ai_tech_seed tech_term 82 AI视频
|
||||||
|
AI workflow ai_tech_seed ai_term 82 AI workflow
|
||||||
|
ai workflow ai_tech_seed ai_term 82 AI workflow
|
||||||
|
AI 工作流 ai_tech_seed ai_term 82 AI workflow
|
||||||
AI-native ai_tech_seed ai_term 82 AI native
|
AI-native ai_tech_seed ai_term 82 AI native
|
||||||
Airbnb ai_tech_seed tech_company 82 Airbnb
|
Airbnb ai_tech_seed tech_company 82 Airbnb
|
||||||
airbnb ai_tech_seed tech_company 82 Airbnb
|
airbnb ai_tech_seed tech_company 82 Airbnb
|
||||||
AI原生 ai_tech_seed ai_term 82 AI native
|
AI原生 ai_tech_seed ai_term 82 AI native
|
||||||
|
AI视频 AI shi pin ai_tech_seed tech_term 82 AI视频
|
||||||
|
Amazon Q Developer ai_tech_seed dev_tool 82 Q Developer
|
||||||
|
Apache Kafka ai_tech_seed tech_term 82 Kafka
|
||||||
|
AutoGen ai_tech_seed ai_platform 82 AutoGen
|
||||||
|
autogen ai_tech_seed ai_platform 82 AutoGen
|
||||||
bai chuan ai_tech_seed ai_brand 82 百川
|
bai chuan ai_tech_seed ai_brand 82 百川
|
||||||
Baichuan ai_tech_seed ai_brand 82 百川
|
Baichuan ai_tech_seed ai_brand 82 百川
|
||||||
baichuan ai_tech_seed ai_brand 82 Baichuan
|
baichuan ai_tech_seed ai_brand 82 Baichuan
|
||||||
blockchain ai_tech_seed tech_term 82 区块链
|
blockchain ai_tech_seed tech_term 82 区块链
|
||||||
|
Bun ai_tech_seed dev_tool 82 Bun
|
||||||
|
bun js ai_tech_seed dev_tool 82 Bun
|
||||||
|
Bun runtime ai_tech_seed dev_tool 82 Bun
|
||||||
Cloudflare ai_tech_seed tech_company 82 Cloudflare
|
Cloudflare ai_tech_seed tech_company 82 Cloudflare
|
||||||
cloudflare ai_tech_seed tech_company 82 Cloudflare
|
cloudflare ai_tech_seed tech_company 82 Cloudflare
|
||||||
Codeium Windsurf ai_tech_seed dev_tool 82 Windsurf
|
Codeium Windsurf ai_tech_seed dev_tool 82 Windsurf
|
||||||
|
Cognition ai_tech_seed ai_brand 82 Cognition
|
||||||
|
cognition ai ai_tech_seed ai_brand 82 Cognition
|
||||||
|
Cognition AI ai_tech_seed ai_brand 82 Cognition
|
||||||
Coinbase ai_tech_seed tech_company 82 Coinbase
|
Coinbase ai_tech_seed tech_company 82 Coinbase
|
||||||
coinbase ai_tech_seed tech_company 82 Coinbase
|
coinbase ai_tech_seed tech_company 82 Coinbase
|
||||||
|
context compression ai_tech_seed ai_term 82 上下文压缩
|
||||||
|
Create ML ai_tech_seed dev_tool 82 Create ML
|
||||||
|
create ml ai_tech_seed dev_tool 82 Create ML
|
||||||
|
CreateML ai_tech_seed dev_tool 82 Create ML
|
||||||
|
crew ai ai_tech_seed ai_platform 82 CrewAI
|
||||||
|
Crew AI ai_tech_seed ai_platform 82 CrewAI
|
||||||
|
CrewAI ai_tech_seed ai_platform 82 CrewAI
|
||||||
deep research ai_tech_seed ai_term 82 deep research
|
deep research ai_tech_seed ai_term 82 deep research
|
||||||
distillation ai_tech_seed ai_term 82 distillation
|
distillation ai_tech_seed ai_term 82 distillation
|
||||||
|
Drizzle ai_tech_seed dev_tool 82 Drizzle
|
||||||
|
drizzle orm ai_tech_seed dev_tool 82 Drizzle
|
||||||
|
Drizzle ORM ai_tech_seed dev_tool 82 Drizzle
|
||||||
|
embodied agent ai_tech_seed ai_term 82 具身智能体
|
||||||
embodied AI ai_tech_seed tech_term 82 具身智能
|
embodied AI ai_tech_seed tech_term 82 具身智能
|
||||||
|
end-to-end ai_tech_seed tech_term 82 端到端
|
||||||
|
fast api ai_tech_seed dev_tool 82 FastAPI
|
||||||
|
FastAPI ai_tech_seed dev_tool 82 FastAPI
|
||||||
full self driving ai_tech_seed tech_term 82 FSD
|
full self driving ai_tech_seed tech_term 82 FSD
|
||||||
Geely ai_tech_seed tech_company 82 Geely
|
Geely ai_tech_seed tech_company 82 Geely
|
||||||
geely ai_tech_seed tech_company 82 Geely
|
geely ai_tech_seed tech_company 82 Geely
|
||||||
@@ -491,17 +778,35 @@ Golang ai_tech_seed dev_tool 82 Go
|
|||||||
Groq ai_tech_seed ai_brand 82 Groq
|
Groq ai_tech_seed ai_brand 82 Groq
|
||||||
groq ai_tech_seed ai_brand 82 Groq
|
groq ai_tech_seed ai_brand 82 Groq
|
||||||
GROQ ai_tech_seed ai_brand 82 Groq
|
GROQ ai_tech_seed ai_brand 82 Groq
|
||||||
|
hailuo ai ai_tech_seed ai_brand 82 Hailuo
|
||||||
horizon ai_tech_seed tech_company 82 Horizon Robotics
|
horizon ai_tech_seed tech_company 82 Horizon Robotics
|
||||||
Horizon Robotics ai_tech_seed tech_company 82 Horizon Robotics
|
Horizon Robotics ai_tech_seed tech_company 82 Horizon Robotics
|
||||||
humanoid robot ai_tech_seed tech_term 82 人形机器人
|
hybrid retrieval ai_tech_seed ai_term 82 hybrid search
|
||||||
|
hybrid search ai_tech_seed ai_term 82 hybrid search
|
||||||
IBM ai_tech_seed tech_company 82 IBM
|
IBM ai_tech_seed tech_company 82 IBM
|
||||||
ibm ai_tech_seed tech_company 82 IBM
|
ibm ai_tech_seed tech_company 82 IBM
|
||||||
|
image-to-video ai_tech_seed tech_term 82 图生视频
|
||||||
Jeff Bezos ai_tech_seed tech_leader 82 Jeff Bezos
|
Jeff Bezos ai_tech_seed tech_leader 82 Jeff Bezos
|
||||||
jeff bezos ai_tech_seed tech_leader 82 Jeff Bezos
|
jeff bezos ai_tech_seed tech_leader 82 Jeff Bezos
|
||||||
|
jimeng ai ai_tech_seed ai_brand 82 Jimeng
|
||||||
|
Kafka ai_tech_seed tech_term 82 Kafka
|
||||||
|
kafka ai_tech_seed tech_term 82 Kafka
|
||||||
ling yi wan wu ai_tech_seed ai_brand 82 零一万物
|
ling yi wan wu ai_tech_seed ai_brand 82 零一万物
|
||||||
|
Live Activities ai_tech_seed dev_tool 82 Live Activities
|
||||||
|
live activities ai_tech_seed dev_tool 82 Live Activities
|
||||||
|
llama index ai_tech_seed ai_platform 82 LlamaIndex
|
||||||
|
LlamaIndex ai_tech_seed ai_platform 82 LlamaIndex
|
||||||
|
LM Studio ai_tech_seed ai_platform 82 LM Studio
|
||||||
|
lm studio ai_tech_seed ai_platform 82 LM Studio
|
||||||
|
LMStudio ai_tech_seed ai_platform 82 LM Studio
|
||||||
|
MCP Inspector ai_tech_seed dev_tool 82 MCP Inspector
|
||||||
|
mcp inspector ai_tech_seed dev_tool 82 MCP Inspector
|
||||||
|
MCP 调试器 ai_tech_seed dev_tool 82 MCP Inspector
|
||||||
|
Microsoft AutoGen ai_tech_seed ai_platform 82 AutoGen
|
||||||
Mongo DB ai_tech_seed tech_company 82 MongoDB
|
Mongo DB ai_tech_seed tech_company 82 MongoDB
|
||||||
MongoDB ai_tech_seed tech_company 82 MongoDB
|
MongoDB ai_tech_seed tech_company 82 MongoDB
|
||||||
mongodb ai_tech_seed tech_company 82 MongoDB
|
mongodb ai_tech_seed tech_company 82 MongoDB
|
||||||
|
new quality productive forces ai_tech_seed tech_term 82 新质生产力
|
||||||
Nintendo ai_tech_seed tech_company 82 Nintendo
|
Nintendo ai_tech_seed tech_company 82 Nintendo
|
||||||
nintendo ai_tech_seed tech_company 82 Nintendo
|
nintendo ai_tech_seed tech_company 82 Nintendo
|
||||||
node js ai_tech_seed dev_tool 82 Node.js
|
node js ai_tech_seed dev_tool 82 Node.js
|
||||||
@@ -516,20 +821,34 @@ Ollama ai_tech_seed ai_platform 82 Ollama
|
|||||||
ollama ai_tech_seed ai_platform 82 Ollama
|
ollama ai_tech_seed ai_platform 82 Ollama
|
||||||
open router ai_tech_seed ai_platform 82 OpenRouter
|
open router ai_tech_seed ai_platform 82 OpenRouter
|
||||||
Open Router ai_tech_seed ai_platform 82 OpenRouter
|
Open Router ai_tech_seed ai_platform 82 OpenRouter
|
||||||
|
open web ui ai_tech_seed ai_platform 82 Open WebUI
|
||||||
|
Open WebUI ai_tech_seed ai_platform 82 Open WebUI
|
||||||
open weight ai_tech_seed ai_term 82 open weight
|
open weight ai_tech_seed ai_term 82 open weight
|
||||||
open weights ai_tech_seed ai_term 82 open weight
|
open weights ai_tech_seed ai_term 82 open weight
|
||||||
OpenRouter ai_tech_seed ai_platform 82 OpenRouter
|
OpenRouter ai_tech_seed ai_platform 82 OpenRouter
|
||||||
|
OpenWebUI ai_tech_seed ai_platform 82 Open WebUI
|
||||||
Optimus ai_tech_seed tech_term 82 人形机器人
|
Optimus ai_tech_seed tech_term 82 人形机器人
|
||||||
Oracle ai_tech_seed tech_company 82 Oracle
|
Oracle ai_tech_seed tech_company 82 Oracle
|
||||||
oracle ai_tech_seed tech_company 82 Oracle
|
oracle ai_tech_seed tech_company 82 Oracle
|
||||||
|
otel ai_tech_seed tech_term 82 OTel
|
||||||
Palantir ai_tech_seed tech_company 82 Palantir
|
Palantir ai_tech_seed tech_company 82 Palantir
|
||||||
palantir ai_tech_seed tech_company 82 Palantir
|
palantir ai_tech_seed tech_company 82 Palantir
|
||||||
PayPal ai_tech_seed tech_company 82 PayPal
|
PayPal ai_tech_seed tech_company 82 PayPal
|
||||||
paypal ai_tech_seed tech_company 82 PayPal
|
paypal ai_tech_seed tech_company 82 PayPal
|
||||||
|
planet scale ai_tech_seed tech_company 82 PlanetScale
|
||||||
|
PlanetScale ai_tech_seed tech_company 82 PlanetScale
|
||||||
|
PlanetScale MySQL ai_tech_seed tech_company 82 PlanetScale
|
||||||
pre-training ai_tech_seed ai_term 82 pretraining
|
pre-training ai_tech_seed ai_term 82 pretraining
|
||||||
pretraining ai_tech_seed ai_term 82 pretraining
|
pretraining ai_tech_seed ai_term 82 pretraining
|
||||||
|
q developer ai_tech_seed dev_tool 82 Q Developer
|
||||||
|
React Server Components ai_tech_seed dev_tool 82 React Server Components
|
||||||
|
react server components ai_tech_seed dev_tool 82 React Server Components
|
||||||
|
red note ai_tech_seed tech_company 82 RedNote
|
||||||
Redis ai_tech_seed tech_company 82 Redis
|
Redis ai_tech_seed tech_company 82 Redis
|
||||||
redis ai_tech_seed tech_company 82 Redis
|
redis ai_tech_seed tech_company 82 Redis
|
||||||
|
rerank ai_tech_seed ai_term 82 reranker
|
||||||
|
reranker ai_tech_seed ai_term 82 reranker
|
||||||
|
RSC ai_tech_seed dev_tool 82 React Server Components
|
||||||
Rust ai_tech_seed dev_tool 82 Rust
|
Rust ai_tech_seed dev_tool 82 Rust
|
||||||
rust ai_tech_seed dev_tool 82 Rust
|
rust ai_tech_seed dev_tool 82 Rust
|
||||||
SaaS ai_tech_seed tech_term 82 SaaS
|
SaaS ai_tech_seed tech_term 82 SaaS
|
||||||
@@ -540,8 +859,14 @@ SDK ai_tech_seed tech_term 82 SDK
|
|||||||
sdk ai_tech_seed tech_term 82 SDK
|
sdk ai_tech_seed tech_term 82 SDK
|
||||||
SenseTime ai_tech_seed tech_company 82 SenseTime
|
SenseTime ai_tech_seed tech_company 82 SenseTime
|
||||||
sensetime ai_tech_seed tech_company 82 SenseTime
|
sensetime ai_tech_seed tech_company 82 SenseTime
|
||||||
|
Sentry ai_tech_seed dev_tool 82 Sentry
|
||||||
|
sentry ai_tech_seed dev_tool 82 Sentry
|
||||||
|
SF Symbols ai_tech_seed dev_tool 82 SF Symbols
|
||||||
|
sf symbols ai_tech_seed dev_tool 82 SF Symbols
|
||||||
|
SFSymbols ai_tech_seed dev_tool 82 SF Symbols
|
||||||
Shopify ai_tech_seed tech_company 82 Shopify
|
Shopify ai_tech_seed tech_company 82 Shopify
|
||||||
shopify ai_tech_seed tech_company 82 Shopify
|
shopify ai_tech_seed tech_company 82 Shopify
|
||||||
|
SPM ai_tech_seed dev_tool 82 Swift Package Manager
|
||||||
Spotify ai_tech_seed tech_company 82 Spotify
|
Spotify ai_tech_seed tech_company 82 Spotify
|
||||||
spotify ai_tech_seed tech_company 82 Spotify
|
spotify ai_tech_seed tech_company 82 Spotify
|
||||||
step fun ai_tech_seed ai_brand 82 StepFun
|
step fun ai_tech_seed ai_brand 82 StepFun
|
||||||
@@ -549,51 +874,85 @@ StepFun ai_tech_seed ai_brand 82 StepFun
|
|||||||
streaming ai_tech_seed ai_term 82 流式
|
streaming ai_tech_seed ai_term 82 流式
|
||||||
Sundar Pichai ai_tech_seed tech_leader 82 Sundar Pichai
|
Sundar Pichai ai_tech_seed tech_leader 82 Sundar Pichai
|
||||||
sundar pichai ai_tech_seed tech_leader 82 Sundar Pichai
|
sundar pichai ai_tech_seed tech_leader 82 Sundar Pichai
|
||||||
|
Swift Package Manager ai_tech_seed dev_tool 82 Swift Package Manager
|
||||||
|
swift package manager ai_tech_seed dev_tool 82 Swift Package Manager
|
||||||
swift ui ai_tech_seed dev_tool 82 SwiftUI
|
swift ui ai_tech_seed dev_tool 82 SwiftUI
|
||||||
Swift UI ai_tech_seed dev_tool 82 SwiftUI
|
Swift UI ai_tech_seed dev_tool 82 SwiftUI
|
||||||
SwiftUI ai_tech_seed dev_tool 82 SwiftUI
|
SwiftUI ai_tech_seed dev_tool 82 SwiftUI
|
||||||
|
text-to-image ai_tech_seed tech_term 82 文生图
|
||||||
|
text-to-video ai_tech_seed tech_term 82 文生视频
|
||||||
|
TRAE ai_tech_seed dev_tool 82 TRAE
|
||||||
vector database ai_tech_seed ai_term 82 vector database
|
vector database ai_tech_seed ai_term 82 vector database
|
||||||
Vercel ai_tech_seed tech_company 82 Vercel
|
Vercel ai_tech_seed tech_company 82 Vercel
|
||||||
vercel ai_tech_seed tech_company 82 Vercel
|
vercel ai_tech_seed tech_company 82 Vercel
|
||||||
|
vision os ai_tech_seed dev_tool 82 visionOS
|
||||||
|
Vision Pro ai_tech_seed dev_tool 82 visionOS
|
||||||
|
visionOS ai_tech_seed dev_tool 82 visionOS
|
||||||
Vue ai_tech_seed dev_tool 82 Vue
|
Vue ai_tech_seed dev_tool 82 Vue
|
||||||
vue ai_tech_seed dev_tool 82 Vue
|
vue ai_tech_seed dev_tool 82 Vue
|
||||||
Vue.js ai_tech_seed dev_tool 82 Vue
|
Vue.js ai_tech_seed dev_tool 82 Vue
|
||||||
Vue3 ai_tech_seed dev_tool 82 Vue
|
Vue3 ai_tech_seed dev_tool 82 Vue
|
||||||
Weibo ai_tech_seed tech_company 82 Weibo
|
Weibo ai_tech_seed tech_company 82 Weibo
|
||||||
weibo ai_tech_seed tech_company 82 Weibo
|
weibo ai_tech_seed tech_company 82 Weibo
|
||||||
|
widget kit ai_tech_seed dev_tool 82 WidgetKit
|
||||||
|
WidgetKit ai_tech_seed dev_tool 82 WidgetKit
|
||||||
Windsurf ai_tech_seed dev_tool 82 Windsurf
|
Windsurf ai_tech_seed dev_tool 82 Windsurf
|
||||||
windsurf ai_tech_seed dev_tool 82 Windsurf
|
windsurf ai_tech_seed dev_tool 82 Windsurf
|
||||||
|
workflow orchestration ai_tech_seed ai_term 82 工作流编排
|
||||||
|
xcode gen ai_tech_seed dev_tool 82 XcodeGen
|
||||||
|
XcodeGen ai_tech_seed dev_tool 82 XcodeGen
|
||||||
Yi ai_tech_seed ai_brand 82 零一万物
|
Yi ai_tech_seed ai_brand 82 零一万物
|
||||||
人形机器人 ren xing ji qi ren ai_tech_seed tech_term 82 人形机器人
|
上下文压缩 shang xia wen ya suo ai_tech_seed ai_term 82 上下文压缩
|
||||||
任天堂 ai_tech_seed tech_company 82 Nintendo
|
任天堂 ai_tech_seed tech_company 82 Nintendo
|
||||||
余承东 yu cheng dong ai_tech_seed tech_leader 82 余承东
|
余承东 yu cheng dong ai_tech_seed tech_leader 82 余承东
|
||||||
|
元宝 yuan bao ai_tech_seed ai_brand 82 元宝
|
||||||
全自动驾驶 ai_tech_seed tech_term 82 FSD
|
全自动驾驶 ai_tech_seed tech_term 82 FSD
|
||||||
具身智能 ju shen zhi neng ai_tech_seed tech_term 82 具身智能
|
具身智能 ju shen zhi neng ai_tech_seed tech_term 82 具身智能
|
||||||
|
具身智能体 ju shen zhi neng ti ai_tech_seed ai_term 82 具身智能体
|
||||||
区块链 qu kuai lian ai_tech_seed tech_term 82 区块链
|
区块链 qu kuai lian ai_tech_seed tech_term 82 区块链
|
||||||
吉利 ai_tech_seed tech_company 82 Geely
|
吉利 ai_tech_seed tech_company 82 Geely
|
||||||
向量数据库 ai_tech_seed ai_term 82 vector database
|
向量数据库 ai_tech_seed ai_term 82 vector database
|
||||||
商汤 ai_tech_seed tech_company 82 SenseTime
|
商汤 ai_tech_seed tech_company 82 SenseTime
|
||||||
|
图生视频 tu sheng shi pin ai_tech_seed tech_term 82 图生视频
|
||||||
地平线 ai_tech_seed tech_company 82 Horizon Robotics
|
地平线 ai_tech_seed tech_company 82 Horizon Robotics
|
||||||
|
字节 Trae ai_tech_seed dev_tool 82 TRAE
|
||||||
|
工作流编排 gong zuo liu bian pai ai_tech_seed ai_term 82 工作流编排
|
||||||
开放权重 ai_tech_seed ai_term 82 open weight
|
开放权重 ai_tech_seed ai_term 82 open weight
|
||||||
微博 ai_tech_seed tech_company 82 Weibo
|
微博 ai_tech_seed tech_company 82 Weibo
|
||||||
|
文生图 wen sheng tu ai_tech_seed tech_term 82 文生图
|
||||||
|
文生视频 wen sheng shi pin ai_tech_seed tech_term 82 文生视频
|
||||||
|
新质生产力 xin zhi sheng chan li ai_tech_seed tech_term 82 新质生产力
|
||||||
流式 liu shi ai_tech_seed ai_term 82 流式
|
流式 liu shi ai_tech_seed ai_term 82 流式
|
||||||
深度研究 ai_tech_seed ai_term 82 deep research
|
深度研究 ai_tech_seed ai_term 82 deep research
|
||||||
|
混合搜索 ai_tech_seed ai_term 82 hybrid search
|
||||||
|
灵动岛实时活动 ai_tech_seed dev_tool 82 Live Activities
|
||||||
百川 ai_tech_seed ai_brand 82 百川
|
百川 ai_tech_seed ai_brand 82 百川
|
||||||
皮查伊 ai_tech_seed tech_leader 82 Sundar Pichai
|
皮查伊 ai_tech_seed tech_leader 82 Sundar Pichai
|
||||||
知识蒸馏 ai_tech_seed ai_term 82 distillation
|
知识蒸馏 ai_tech_seed ai_term 82 distillation
|
||||||
|
端到端 duan dao duan ai_tech_seed tech_term 82 端到端
|
||||||
纳德拉 ai_tech_seed tech_leader 82 Satya Nadella
|
纳德拉 ai_tech_seed tech_leader 82 Satya Nadella
|
||||||
蒸馏 ai_tech_seed ai_term 82 distillation
|
蒸馏 ai_tech_seed ai_term 82 distillation
|
||||||
贝索斯 ai_tech_seed tech_leader 82 Jeff Bezos
|
贝索斯 ai_tech_seed tech_leader 82 Jeff Bezos
|
||||||
|
重排序模型 ai_tech_seed ai_term 82 reranker
|
||||||
阶跃星辰 ai_tech_seed ai_brand 82 StepFun
|
阶跃星辰 ai_tech_seed ai_brand 82 StepFun
|
||||||
零一万物 ai_tech_seed ai_brand 82 零一万物
|
零一万物 ai_tech_seed ai_brand 82 零一万物
|
||||||
预训练 ai_tech_seed ai_term 82 pretraining
|
预训练 ai_tech_seed ai_term 82 pretraining
|
||||||
01 ai ai_tech_seed ai_brand 80 01.AI
|
01 ai ai_tech_seed ai_brand 80 01.AI
|
||||||
|
agent harness ai_tech_seed ai_term 80 LLM harness
|
||||||
|
agentic harness ai_tech_seed ai_term 80 agent harness
|
||||||
|
Astro ai_tech_seed dev_tool 80 Astro
|
||||||
|
astro js ai_tech_seed dev_tool 80 Astro
|
||||||
Atlassian ai_tech_seed tech_company 80 Atlassian
|
Atlassian ai_tech_seed tech_company 80 Atlassian
|
||||||
atlassian ai_tech_seed tech_company 80 Atlassian
|
atlassian ai_tech_seed tech_company 80 Atlassian
|
||||||
|
authentically human ai_tech_seed tech_term 80 活人感
|
||||||
Broadcom ai_tech_seed tech_company 80 Broadcom
|
Broadcom ai_tech_seed tech_company 80 Broadcom
|
||||||
broadcom ai_tech_seed tech_company 80 Broadcom
|
broadcom ai_tech_seed tech_company 80 Broadcom
|
||||||
Canva ai_tech_seed tech_company 80 Canva
|
Canva ai_tech_seed tech_company 80 Canva
|
||||||
canva ai_tech_seed tech_company 80 Canva
|
canva ai_tech_seed tech_company 80 Canva
|
||||||
|
Clerk ai_tech_seed dev_tool 80 Clerk
|
||||||
|
clerk auth ai_tech_seed dev_tool 80 Clerk
|
||||||
|
click house ai_tech_seed tech_term 80 ClickHouse
|
||||||
|
ClickHouse ai_tech_seed tech_term 80 ClickHouse
|
||||||
Codeium ai_tech_seed ai_brand 80 Codeium
|
Codeium ai_tech_seed ai_brand 80 Codeium
|
||||||
codeium ai_tech_seed ai_brand 80 Codeium
|
codeium ai_tech_seed ai_brand 80 Codeium
|
||||||
Cohere ai_tech_seed ai_brand 80 Cohere
|
Cohere ai_tech_seed ai_brand 80 Cohere
|
||||||
@@ -601,11 +960,21 @@ cohere ai_tech_seed ai_brand 80 Cohere
|
|||||||
computer use ai_tech_seed ai_term 80 computer use
|
computer use ai_tech_seed ai_term 80 computer use
|
||||||
computer-use ai_tech_seed ai_term 80 computer use
|
computer-use ai_tech_seed ai_term 80 computer use
|
||||||
Confluence ai_tech_seed tech_company 80 Atlassian
|
Confluence ai_tech_seed tech_company 80 Atlassian
|
||||||
|
Convex ai_tech_seed tech_company 80 Convex
|
||||||
|
Convex database ai_tech_seed tech_company 80 Convex
|
||||||
|
convex dev ai_tech_seed tech_company 80 Convex
|
||||||
|
Deno ai_tech_seed dev_tool 80 Deno
|
||||||
|
deno ai_tech_seed dev_tool 80 Deno
|
||||||
|
Deno Deploy ai_tech_seed dev_tool 80 Deno
|
||||||
Discord ai_tech_seed tech_company 80 Discord
|
Discord ai_tech_seed tech_company 80 Discord
|
||||||
discord ai_tech_seed tech_company 80 Discord
|
discord ai_tech_seed tech_company 80 Discord
|
||||||
|
duck db ai_tech_seed tech_term 80 DuckDB
|
||||||
|
Duck DB ai_tech_seed tech_term 80 DuckDB
|
||||||
|
DuckDB ai_tech_seed tech_term 80 DuckDB
|
||||||
eleven labs ai_tech_seed ai_brand 80 ElevenLabs
|
eleven labs ai_tech_seed ai_brand 80 ElevenLabs
|
||||||
Eleven Labs ai_tech_seed ai_brand 80 ElevenLabs
|
Eleven Labs ai_tech_seed ai_brand 80 ElevenLabs
|
||||||
ElevenLabs ai_tech_seed ai_brand 80 ElevenLabs
|
ElevenLabs ai_tech_seed ai_brand 80 ElevenLabs
|
||||||
|
emotional value ai_tech_seed tech_term 80 情绪价值
|
||||||
Firebase ai_tech_seed tech_company 80 Firebase
|
Firebase ai_tech_seed tech_company 80 Firebase
|
||||||
firebase ai_tech_seed tech_company 80 Firebase
|
firebase ai_tech_seed tech_company 80 Firebase
|
||||||
GitLab ai_tech_seed tech_company 80 GitLab
|
GitLab ai_tech_seed tech_company 80 GitLab
|
||||||
@@ -615,16 +984,30 @@ Jira ai_tech_seed tech_company 80 Atlassian
|
|||||||
lang chain ai_tech_seed ai_platform 80 LangChain
|
lang chain ai_tech_seed ai_platform 80 LangChain
|
||||||
LangChain ai_tech_seed ai_platform 80 LangChain
|
LangChain ai_tech_seed ai_platform 80 LangChain
|
||||||
Langchain ai_tech_seed ai_platform 80 LangChain
|
Langchain ai_tech_seed ai_platform 80 LangChain
|
||||||
|
libSQL ai_tech_seed tech_company 80 Turso
|
||||||
|
LLM harness ai_tech_seed ai_term 80 LLM harness
|
||||||
|
llm harness ai_tech_seed ai_term 80 LLM harness
|
||||||
low altitude economy ai_tech_seed tech_term 80 低空经济
|
low altitude economy ai_tech_seed tech_term 80 低空经济
|
||||||
Megvii ai_tech_seed tech_company 80 Megvii
|
Megvii ai_tech_seed tech_company 80 Megvii
|
||||||
megvii ai_tech_seed tech_company 80 Megvii
|
megvii ai_tech_seed tech_company 80 Megvii
|
||||||
|
Metal ai_tech_seed dev_tool 80 Metal
|
||||||
|
metal ai_tech_seed dev_tool 80 Metal
|
||||||
|
Metal Performance Shaders ai_tech_seed dev_tool 80 Metal
|
||||||
|
Metaso ai_tech_seed ai_brand 80 秘塔AI
|
||||||
on device ai ai_tech_seed ai_term 80 on-device AI
|
on device ai ai_tech_seed ai_term 80 on-device AI
|
||||||
on-device AI ai_tech_seed ai_term 80 on-device AI
|
on-device AI ai_tech_seed ai_term 80 on-device AI
|
||||||
optimus ai_tech_seed tech_term 80 Optimus
|
optimus ai_tech_seed tech_term 80 Optimus
|
||||||
|
Quark AI ai_tech_seed ai_brand 80 夸克AI
|
||||||
|
real person vibe ai_tech_seed tech_term 80 活人感
|
||||||
|
reality kit ai_tech_seed dev_tool 80 RealityKit
|
||||||
|
RealityKit ai_tech_seed dev_tool 80 RealityKit
|
||||||
Reddit ai_tech_seed tech_company 80 Reddit
|
Reddit ai_tech_seed tech_company 80 Reddit
|
||||||
reddit ai_tech_seed tech_company 80 Reddit
|
reddit ai_tech_seed tech_company 80 Reddit
|
||||||
Rivian ai_tech_seed tech_company 80 Rivian
|
Rivian ai_tech_seed tech_company 80 Rivian
|
||||||
rivian ai_tech_seed tech_company 80 Rivian
|
rivian ai_tech_seed tech_company 80 Rivian
|
||||||
|
Roo Code ai_tech_seed dev_tool 80 Roo Code
|
||||||
|
roo code ai_tech_seed dev_tool 80 Roo Code
|
||||||
|
RooCode ai_tech_seed dev_tool 80 Roo Code
|
||||||
Runway ai_tech_seed ai_brand 80 Runway
|
Runway ai_tech_seed ai_brand 80 Runway
|
||||||
runway ai ai_tech_seed ai_brand 80 Runway
|
runway ai ai_tech_seed ai_brand 80 Runway
|
||||||
Runway ML ai_tech_seed ai_brand 80 Runway
|
Runway ML ai_tech_seed ai_brand 80 Runway
|
||||||
@@ -639,69 +1022,148 @@ Snowflake ai_tech_seed tech_company 80 Snowflake
|
|||||||
snowflake ai_tech_seed tech_company 80 Snowflake
|
snowflake ai_tech_seed tech_company 80 Snowflake
|
||||||
Supabase ai_tech_seed tech_company 80 Supabase
|
Supabase ai_tech_seed tech_company 80 Supabase
|
||||||
supabase ai_tech_seed tech_company 80 Supabase
|
supabase ai_tech_seed tech_company 80 Supabase
|
||||||
|
svelte kit ai_tech_seed dev_tool 80 SvelteKit
|
||||||
|
SvelteKit ai_tech_seed dev_tool 80 SvelteKit
|
||||||
|
swe agent ai_tech_seed ai_term 80 SWE-agent
|
||||||
|
SWE Agent ai_tech_seed ai_term 80 SWE-agent
|
||||||
swe bench ai_tech_seed ai_term 80 SWE-bench
|
swe bench ai_tech_seed ai_term 80 SWE-bench
|
||||||
SWE bench ai_tech_seed ai_term 80 SWE-bench
|
SWE bench ai_tech_seed ai_term 80 SWE-bench
|
||||||
|
SWE-agent ai_tech_seed ai_term 80 SWE-agent
|
||||||
SWE-bench ai_tech_seed ai_term 80 SWE-bench
|
SWE-bench ai_tech_seed ai_term 80 SWE-bench
|
||||||
Terraform ai_tech_seed tech_company 80 Terraform
|
Terraform ai_tech_seed tech_company 80 Terraform
|
||||||
terraform ai_tech_seed tech_company 80 Terraform
|
terraform ai_tech_seed tech_company 80 Terraform
|
||||||
TPU ai_tech_seed tech_term 80 TPU
|
TPU ai_tech_seed tech_term 80 TPU
|
||||||
tpu ai_tech_seed tech_term 80 TPU
|
tpu ai_tech_seed tech_term 80 TPU
|
||||||
|
Turso ai_tech_seed tech_company 80 Turso
|
||||||
|
turso ai_tech_seed tech_company 80 Turso
|
||||||
vLLM ai_tech_seed ai_platform 80 vLLM
|
vLLM ai_tech_seed ai_platform 80 vLLM
|
||||||
vllm ai_tech_seed ai_platform 80 vLLM
|
vllm ai_tech_seed ai_platform 80 vLLM
|
||||||
VLLM ai_tech_seed ai_platform 80 vLLM
|
VLLM ai_tech_seed ai_platform 80 vLLM
|
||||||
|
Zed ai_tech_seed dev_tool 80 Zed
|
||||||
|
Zed AI ai_tech_seed dev_tool 80 Zed
|
||||||
|
zed editor ai_tech_seed dev_tool 80 Zed
|
||||||
Zoom ai_tech_seed tech_company 80 Zoom
|
Zoom ai_tech_seed tech_company 80 Zoom
|
||||||
zoom ai_tech_seed tech_company 80 Zoom
|
zoom ai_tech_seed tech_company 80 Zoom
|
||||||
低空经济 di kong jing ji ai_tech_seed tech_term 80 低空经济
|
低空经济 di kong jing ji ai_tech_seed tech_term 80 低空经济
|
||||||
|
夸克AI kua ke AI ai_tech_seed ai_brand 80 夸克AI
|
||||||
小语言模型 ai_tech_seed ai_term 80 SLM
|
小语言模型 ai_tech_seed ai_term 80 SLM
|
||||||
|
情绪价值 qing xu jia zhi ai_tech_seed tech_term 80 情绪价值
|
||||||
擎天柱 ai_tech_seed tech_term 80 Optimus
|
擎天柱 ai_tech_seed tech_term 80 Optimus
|
||||||
旷视 ai_tech_seed tech_company 80 Megvii
|
旷视 ai_tech_seed tech_company 80 Megvii
|
||||||
|
智能体框架 ai_tech_seed ai_term 80 agent harness
|
||||||
|
活人感 huo ren gan ai_tech_seed tech_term 80 活人感
|
||||||
电脑使用 ai_tech_seed ai_term 80 computer use
|
电脑使用 ai_tech_seed ai_term 80 computer use
|
||||||
|
秘塔AI mi ta AI ai_tech_seed ai_brand 80 秘塔AI
|
||||||
端侧AI ai_tech_seed ai_term 80 on-device AI
|
端侧AI ai_tech_seed ai_term 80 on-device AI
|
||||||
|
anything llm ai_tech_seed ai_platform 78 AnythingLLM
|
||||||
|
Anything LLM ai_tech_seed ai_platform 78 AnythingLLM
|
||||||
|
AnythingLLM ai_tech_seed ai_platform 78 AnythingLLM
|
||||||
|
auth zero ai_tech_seed dev_tool 78 Auth0
|
||||||
|
Auth Zero ai_tech_seed dev_tool 78 Auth0
|
||||||
|
Auth0 ai_tech_seed dev_tool 78 Auth0
|
||||||
benchmark ai_tech_seed ai_term 78 benchmark
|
benchmark ai_tech_seed ai_term 78 benchmark
|
||||||
|
Biome ai_tech_seed dev_tool 78 Biome
|
||||||
|
biome js ai_tech_seed dev_tool 78 Biome
|
||||||
Block ai_tech_seed fintech 78 Block
|
Block ai_tech_seed fintech 78 Block
|
||||||
block ai_tech_seed fintech 78 Block
|
block ai_tech_seed fintech 78 Block
|
||||||
Character AI ai_tech_seed ai_brand 78 Character AI
|
Character AI ai_tech_seed ai_brand 78 Character AI
|
||||||
character.ai ai_tech_seed ai_brand 78 Character AI
|
character.ai ai_tech_seed ai_brand 78 Character AI
|
||||||
Character.AI ai_tech_seed ai_brand 78 Character AI
|
Character.AI ai_tech_seed ai_brand 78 Character AI
|
||||||
|
Clerk Auth ai_tech_seed dev_tool 78 Clerk Auth
|
||||||
Cline ai_tech_seed dev_tool 78 Cline
|
Cline ai_tech_seed dev_tool 78 Cline
|
||||||
cline ai_tech_seed dev_tool 78 Cline
|
cline ai_tech_seed dev_tool 78 Cline
|
||||||
Cline AI ai_tech_seed dev_tool 78 Cline
|
Cline AI ai_tech_seed dev_tool 78 Cline
|
||||||
|
comfy ui ai_tech_seed ai_platform 78 ComfyUI
|
||||||
|
ComfyUI ai_tech_seed ai_platform 78 ComfyUI
|
||||||
|
Continue ai_tech_seed dev_tool 78 Continue
|
||||||
|
continue dev ai_tech_seed dev_tool 78 Continue
|
||||||
|
Continue.dev ai_tech_seed dev_tool 78 Continue
|
||||||
|
cyber reconciliation ai_tech_seed tech_term 78 赛博对账
|
||||||
Datadog ai_tech_seed tech_company 78 Datadog
|
Datadog ai_tech_seed tech_company 78 Datadog
|
||||||
datadog ai_tech_seed tech_company 78 Datadog
|
datadog ai_tech_seed tech_company 78 Datadog
|
||||||
|
digital avatar ai_tech_seed tech_term 78 数字分身
|
||||||
|
DSPy ai_tech_seed ai_platform 78 DSPy
|
||||||
|
dspy ai_tech_seed ai_platform 78 DSPy
|
||||||
|
DSPy AI ai_tech_seed ai_platform 78 DSPy
|
||||||
Elastic ai_tech_seed tech_company 78 Elastic
|
Elastic ai_tech_seed tech_company 78 Elastic
|
||||||
elastic ai_tech_seed tech_company 78 Elastic
|
elastic ai_tech_seed tech_company 78 Elastic
|
||||||
Elasticsearch ai_tech_seed tech_company 78 Elastic
|
Elasticsearch ai_tech_seed tech_company 78 Elastic
|
||||||
|
Fly ai_tech_seed tech_company 78 Fly.io
|
||||||
|
fly io ai_tech_seed tech_company 78 Fly.io
|
||||||
|
Fly.io ai_tech_seed tech_company 78 Fly.io
|
||||||
go global ai_tech_seed tech_term 78 出海
|
go global ai_tech_seed tech_term 78 出海
|
||||||
|
Grafana ai_tech_seed dev_tool 78 Grafana
|
||||||
|
grafana ai_tech_seed dev_tool 78 Grafana
|
||||||
Great Wall ai_tech_seed tech_company 78 Great Wall
|
Great Wall ai_tech_seed tech_company 78 Great Wall
|
||||||
great wall ai_tech_seed tech_company 78 Great Wall
|
great wall ai_tech_seed tech_company 78 Great Wall
|
||||||
|
Hono ai_tech_seed dev_tool 78 Hono
|
||||||
|
hono js ai_tech_seed dev_tool 78 Hono
|
||||||
|
Hugging Face Spaces ai_tech_seed ai_platform 78 Hugging Face Spaces
|
||||||
|
hugging face spaces ai_tech_seed ai_platform 78 Hugging Face Spaces
|
||||||
Jupyter ai_tech_seed dev_tool 78 Jupyter
|
Jupyter ai_tech_seed dev_tool 78 Jupyter
|
||||||
jupyter ai_tech_seed dev_tool 78 Jupyter
|
jupyter ai_tech_seed dev_tool 78 Jupyter
|
||||||
Jupyter Notebook ai_tech_seed dev_tool 78 Jupyter
|
Jupyter Notebook ai_tech_seed dev_tool 78 Jupyter
|
||||||
|
lib sql ai_tech_seed tech_term 78 libSQL
|
||||||
Linear ai_tech_seed tech_company 78 Linear
|
Linear ai_tech_seed tech_company 78 Linear
|
||||||
linear ai_tech_seed dev_tool 78 Linear
|
linear ai_tech_seed dev_tool 78 Linear
|
||||||
linear app ai_tech_seed tech_company 78 Linear
|
linear app ai_tech_seed tech_company 78 Linear
|
||||||
Lucid ai_tech_seed tech_company 78 Lucid
|
Lucid ai_tech_seed tech_company 78 Lucid
|
||||||
lucid motors ai_tech_seed tech_company 78 Lucid
|
lucid motors ai_tech_seed tech_company 78 Lucid
|
||||||
Lucid Motors ai_tech_seed tech_company 78 Lucid
|
Lucid Motors ai_tech_seed tech_company 78 Lucid
|
||||||
|
MCP HTTP ai_tech_seed ai_term 78 Streamable HTTP
|
||||||
|
memory bank ai_tech_seed ai_term 78 memory bank
|
||||||
|
metaso ai_tech_seed ai_brand 78 Metaso
|
||||||
metaverse ai_tech_seed tech_term 78 元宇宙
|
metaverse ai_tech_seed tech_term 78 元宇宙
|
||||||
MiniCPM ai_tech_seed ai_brand 78 面壁智能
|
MiniCPM ai_tech_seed ai_brand 78 面壁智能
|
||||||
|
Modal ai_tech_seed tech_company 78 Modal
|
||||||
|
modal labs ai_tech_seed tech_company 78 Modal
|
||||||
|
Nami AI ai_tech_seed ai_brand 78 纳米AI
|
||||||
|
Nix ai_tech_seed tech_term 78 Nix
|
||||||
|
nix ai_tech_seed tech_term 78 Nix
|
||||||
|
NixOS ai_tech_seed tech_term 78 Nix
|
||||||
|
Nuxt ai_tech_seed dev_tool 78 Nuxt
|
||||||
|
nuxt ai_tech_seed dev_tool 78 Nuxt
|
||||||
|
Nuxt.js ai_tech_seed dev_tool 78 Nuxt
|
||||||
Postman ai_tech_seed dev_tool 78 Postman
|
Postman ai_tech_seed dev_tool 78 Postman
|
||||||
postman ai_tech_seed dev_tool 78 Postman
|
postman ai_tech_seed dev_tool 78 Postman
|
||||||
|
Prometheus ai_tech_seed dev_tool 78 Prometheus
|
||||||
|
prometheus ai_tech_seed dev_tool 78 Prometheus
|
||||||
|
Quark ai_tech_seed ai_brand 78 Quark
|
||||||
|
quark ai ai_tech_seed ai_brand 78 Quark
|
||||||
RAG pipeline ai_tech_seed ai_term 78 RAG pipeline
|
RAG pipeline ai_tech_seed ai_term 78 RAG pipeline
|
||||||
rag pipeline ai_tech_seed ai_term 78 RAG pipeline
|
rag pipeline ai_tech_seed ai_term 78 RAG pipeline
|
||||||
|
Railway ai_tech_seed tech_company 78 Railway
|
||||||
|
railway app ai_tech_seed tech_company 78 Railway
|
||||||
|
React Server Actions ai_tech_seed dev_tool 78 Server Actions
|
||||||
|
reasoning effort ai_tech_seed ai_term 78 reasoning effort
|
||||||
|
Remix ai_tech_seed dev_tool 78 Remix
|
||||||
|
remix run ai_tech_seed dev_tool 78 Remix
|
||||||
remote work ai_tech_seed tech_term 78 远程办公
|
remote work ai_tech_seed tech_term 78 远程办公
|
||||||
Replicate ai_tech_seed ai_platform 78 Replicate
|
Replicate ai_tech_seed ai_platform 78 Replicate
|
||||||
replicate ai_tech_seed ai_platform 78 Replicate
|
replicate ai_tech_seed ai_platform 78 Replicate
|
||||||
Replit ai_tech_seed dev_tool 78 Replit
|
Replit ai_tech_seed dev_tool 78 Replit
|
||||||
replit ai_tech_seed dev_tool 78 Replit
|
replit ai_tech_seed dev_tool 78 Replit
|
||||||
Replit Agent ai_tech_seed dev_tool 78 Replit
|
|
||||||
robin hood ai_tech_seed fintech 78 Robinhood
|
robin hood ai_tech_seed fintech 78 Robinhood
|
||||||
Robin Hood ai_tech_seed fintech 78 Robinhood
|
Robin Hood ai_tech_seed fintech 78 Robinhood
|
||||||
Robinhood ai_tech_seed fintech 78 Robinhood
|
Robinhood ai_tech_seed fintech 78 Robinhood
|
||||||
|
Server Actions ai_tech_seed dev_tool 78 Server Actions
|
||||||
|
server actions ai_tech_seed dev_tool 78 Server Actions
|
||||||
service now ai_tech_seed tech_company 78 ServiceNow
|
service now ai_tech_seed tech_company 78 ServiceNow
|
||||||
ServiceNow ai_tech_seed tech_company 78 ServiceNow
|
ServiceNow ai_tech_seed tech_company 78 ServiceNow
|
||||||
|
Spaces ai_tech_seed ai_platform 78 Hugging Face Spaces
|
||||||
Square ai_tech_seed fintech 78 Block
|
Square ai_tech_seed fintech 78 Block
|
||||||
square ai_tech_seed fintech 78 Block
|
square ai_tech_seed fintech 78 Block
|
||||||
|
Streamable HTTP ai_tech_seed ai_term 78 Streamable HTTP
|
||||||
|
streamable http ai_tech_seed ai_term 78 Streamable HTTP
|
||||||
|
Tailscale ai_tech_seed tech_company 78 Tailscale
|
||||||
|
tailscale ai_tech_seed tech_company 78 Tailscale
|
||||||
|
test time compute ai_tech_seed ai_term 78 test-time compute
|
||||||
|
test-time compute ai_tech_seed ai_term 78 test-time compute
|
||||||
|
tiangong ai_tech_seed ai_brand 78 天工
|
||||||
|
Tuist ai_tech_seed dev_tool 78 Tuist
|
||||||
|
tuist ai_tech_seed dev_tool 78 Tuist
|
||||||
|
turbo repo ai_tech_seed dev_tool 78 Turborepo
|
||||||
|
Turborepo ai_tech_seed dev_tool 78 Turborepo
|
||||||
Twilio ai_tech_seed tech_company 78 Twilio
|
Twilio ai_tech_seed tech_company 78 Twilio
|
||||||
twilio ai_tech_seed tech_company 78 Twilio
|
twilio ai_tech_seed tech_company 78 Twilio
|
||||||
Visa ai_tech_seed tech_company 78 Visa
|
Visa ai_tech_seed tech_company 78 Visa
|
||||||
@@ -709,13 +1171,53 @@ visa ai_tech_seed tech_company 78 Visa
|
|||||||
Web 3 ai_tech_seed tech_term 78 Web3
|
Web 3 ai_tech_seed tech_term 78 Web3
|
||||||
Web3 ai_tech_seed tech_term 78 Web3
|
Web3 ai_tech_seed tech_term 78 Web3
|
||||||
web3 ai_tech_seed tech_term 78 Web3
|
web3 ai_tech_seed tech_term 78 Web3
|
||||||
|
work os ai_tech_seed dev_tool 78 WorkOS
|
||||||
|
Work OS ai_tech_seed dev_tool 78 WorkOS
|
||||||
|
WorkOS ai_tech_seed dev_tool 78 WorkOS
|
||||||
元宇宙 yuan yu zhou ai_tech_seed tech_term 78 元宇宙
|
元宇宙 yuan yu zhou ai_tech_seed tech_term 78 元宇宙
|
||||||
全球化 chu hai ai_tech_seed tech_term 78 出海
|
全球化 chu hai ai_tech_seed tech_term 78 出海
|
||||||
出海 chu hai ai_tech_seed tech_term 78 出海
|
出海 chu hai ai_tech_seed tech_term 78 出海
|
||||||
基准测试 ai_tech_seed ai_term 78 benchmark
|
基准测试 ai_tech_seed ai_term 78 benchmark
|
||||||
|
天工 tian gong ai_tech_seed ai_brand 78 天工
|
||||||
|
夸克 ai_tech_seed ai_brand 78 Quark
|
||||||
|
推理强度 ai_tech_seed ai_term 78 reasoning effort
|
||||||
|
数字分身 shu zi fen shen ai_tech_seed tech_term 78 数字分身
|
||||||
|
昆仑万维天工 tian gong ai_tech_seed ai_brand 78 天工
|
||||||
|
测试时计算 ai_tech_seed ai_term 78 test-time compute
|
||||||
|
纳米AI na mi AI ai_tech_seed ai_brand 78 纳米AI
|
||||||
|
记忆库 ai_tech_seed ai_term 78 memory bank
|
||||||
|
赛博对账 sai bo dui zhang ai_tech_seed tech_term 78 赛博对账
|
||||||
远程办公 yuan cheng ban gong ai_tech_seed tech_term 78 远程办公
|
远程办公 yuan cheng ban gong ai_tech_seed tech_term 78 远程办公
|
||||||
长城汽车 ai_tech_seed tech_company 78 Great Wall
|
长城汽车 ai_tech_seed tech_company 78 Great Wall
|
||||||
面壁智能 mian bi zhi neng ai_tech_seed ai_brand 78 面壁智能
|
面壁智能 mian bi zhi neng ai_tech_seed ai_brand 78 面壁智能
|
||||||
|
cyber ai_tech_seed tech_term 76 赛博
|
||||||
|
Deepset Haystack ai_tech_seed ai_platform 76 Haystack
|
||||||
|
Haystack ai_tech_seed ai_platform 76 Haystack
|
||||||
|
haystack ai ai_tech_seed ai_platform 76 Haystack
|
||||||
|
Ktor ai_tech_seed dev_tool 76 Ktor
|
||||||
|
ktor ai_tech_seed dev_tool 76 Ktor
|
||||||
|
Ktor server ai_tech_seed dev_tool 76 Ktor
|
||||||
|
Modal Labs ai_tech_seed tech_company 76 Modal Labs
|
||||||
|
Nix OS ai_tech_seed tech_term 76 NixOS
|
||||||
|
nixos ai_tech_seed tech_term 76 NixOS
|
||||||
|
Nx ai_tech_seed dev_tool 76 Nx
|
||||||
|
nx monorepo ai_tech_seed dev_tool 76 Nx
|
||||||
|
pnpm ai_tech_seed dev_tool 76 pnpm
|
||||||
|
red panda ai_tech_seed tech_company 76 Redpanda
|
||||||
|
Redpanda ai_tech_seed tech_company 76 Redpanda
|
||||||
|
Render ai_tech_seed tech_company 76 Render
|
||||||
|
render.com ai_tech_seed tech_company 76 Render
|
||||||
|
SenseChat ai_tech_seed ai_brand 76 商量
|
||||||
|
Serverless Stack ai_tech_seed dev_tool 76 SST
|
||||||
|
SST ai_tech_seed dev_tool 76 SST
|
||||||
|
sst ai_tech_seed dev_tool 76 SST
|
||||||
|
stdio transport ai_tech_seed ai_term 76 stdio transport
|
||||||
|
web dev arena ai_tech_seed ai_term 76 WebDev Arena
|
||||||
|
WebDev Arena ai_tech_seed ai_term 76 WebDev Arena
|
||||||
|
WebDevArena ai_tech_seed ai_term 76 WebDev Arena
|
||||||
|
商量 shang liang ai_tech_seed ai_brand 76 商量
|
||||||
|
标准输入输出传输 ai_tech_seed ai_term 76 stdio transport
|
||||||
|
赛博 sai bo ai_tech_seed tech_term 76 赛博
|
||||||
Aider ai_tech_seed ai_brand 75 Aider
|
Aider ai_tech_seed ai_brand 75 Aider
|
||||||
aider ai_tech_seed ai_brand 75 Aider
|
aider ai_tech_seed ai_brand 75 Aider
|
||||||
Bitbucket ai_tech_seed tech_company 75 Bitbucket
|
Bitbucket ai_tech_seed tech_company 75 Bitbucket
|
||||||
@@ -744,7 +1246,15 @@ warp terminal ai_tech_seed dev_tool 75 Warp
|
|||||||
数字游民 shu zi you min ai_tech_seed tech_term 75 数字游民
|
数字游民 shu zi you min ai_tech_seed tech_term 75 数字游民
|
||||||
越狱 ai_tech_seed ai_term 75 jailbreak
|
越狱 ai_tech_seed ai_term 75 jailbreak
|
||||||
面壁 ai_tech_seed ai_model 75 MiniCPM
|
面壁 ai_tech_seed ai_model 75 MiniCPM
|
||||||
|
Focus AI ai_tech_seed ai_platform 72 Fooocus
|
||||||
|
Fooocus ai_tech_seed ai_platform 72 Fooocus
|
||||||
|
fooocus ai_tech_seed ai_platform 72 Fooocus
|
||||||
|
Labubu ai_tech_seed tech_term 72 拉布布
|
||||||
|
labubu ai_tech_seed tech_term 72 Labubu
|
||||||
lying flat ai_tech_seed tech_term 72 躺平
|
lying flat ai_tech_seed tech_term 72 躺平
|
||||||
side hustle ai_tech_seed tech_term 72 副业
|
side hustle ai_tech_seed tech_term 72 副业
|
||||||
|
village coffee ai_tech_seed tech_term 72 村咖
|
||||||
副业 fu ye ai_tech_seed tech_term 72 副业
|
副业 fu ye ai_tech_seed tech_term 72 副业
|
||||||
|
拉布布 la bu bu ai_tech_seed tech_term 72 拉布布
|
||||||
|
村咖 cun ka ai_tech_seed tech_term 72 村咖
|
||||||
躺平 tang ping ai_tech_seed tech_term 72 躺平
|
躺平 tang ping ai_tech_seed tech_term 72 躺平
|
||||||
|
|||||||
|
Binary file not shown.
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"bin_bytes" : 1976593,
|
|
||||||
"bin_file" : "OSGKeyboardCLM.bin",
|
|
||||||
"export_seconds" : 0.28117799758911133,
|
|
||||||
"generated_at" : "2026-07-05T08:19:00Z",
|
|
||||||
"identifier" : "com.osgkeyboard.custom-lm.v1",
|
|
||||||
"locale" : "zh_CN",
|
|
||||||
"phrase_count" : 129403,
|
|
||||||
"sources" : {
|
|
||||||
"ai_tech_seed" : 749,
|
|
||||||
"sogou_v1" : 128743
|
|
||||||
},
|
|
||||||
"version" : "1.0.0"
|
|
||||||
}
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"client_identifier" : "com.osgkeyboard.custom-lm.v1",
|
|
||||||
"configuration" : {
|
|
||||||
"language_model" : "\/Users\/rocky\/Documents\/OSGKeyboard\/OSGKeyboard\/Resources\/CustomLanguageModel\/v1\/compiled\/LM",
|
|
||||||
"vocabulary" : "\/Users\/rocky\/Documents\/OSGKeyboard\/OSGKeyboard\/Resources\/CustomLanguageModel\/v1\/compiled\/Vocab",
|
|
||||||
"weight" : null
|
|
||||||
},
|
|
||||||
"generated_at" : "2026-07-05T08:24:55Z",
|
|
||||||
"input_bin" : "OSGKeyboardCLM.bin",
|
|
||||||
"input_bin_bytes" : 1976593,
|
|
||||||
"language_model" : "LM",
|
|
||||||
"language_model_bytes" : 6398585,
|
|
||||||
"prepare_seconds" : 28.565693974494934,
|
|
||||||
"vocabulary" : "Vocab",
|
|
||||||
"vocabulary_bytes" : 178816
|
|
||||||
}
|
|
||||||
@@ -1,32 +1,20 @@
|
|||||||
{
|
{
|
||||||
"version": "v1",
|
"version": "v1",
|
||||||
"generated_at": "2026-07-05T07:47:30.162461+00:00",
|
"generated_at": "2026-07-05T11:27:48.823095+00:00",
|
||||||
"locale": "zh-Hans",
|
"locale": "zh-Hans",
|
||||||
"entry_count": 128743,
|
"entry_count": 10300,
|
||||||
"sources": [
|
"sources": [
|
||||||
{
|
{
|
||||||
"key": "computer_terms",
|
"key": "computer_terms",
|
||||||
"label": "计算机词汇大全【官方推荐】",
|
"label": "计算机词汇大全【官方推荐】",
|
||||||
"weight": 5,
|
"weight": 5,
|
||||||
"raw_count": 10300
|
"raw_count": 10300
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "network_slang_local",
|
|
||||||
"label": "网络流行新词.scel",
|
|
||||||
"weight": 3,
|
|
||||||
"raw_count": 118599
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "sogou_popular_accumulated",
|
|
||||||
"label": "SogouPopularDict accumulated",
|
|
||||||
"weight": 1,
|
|
||||||
"raw_count": 118602
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"notes": [
|
"notes": [
|
||||||
"Sogou-derived data is for internal ASR experimentation only.",
|
"Domain-specific computer/IT vocabulary only; casual network slang and Sogou popular words removed.",
|
||||||
"PhraseCount weights map to SFCustomLanguageModelData relative frequencies.",
|
"PhraseCount weights map to SFCustomLanguageModelData relative frequencies.",
|
||||||
"Higher source weight wins on duplicate words."
|
"Merged with ai-tech-brands seed at export time for the final .bin asset."
|
||||||
],
|
],
|
||||||
"files": {
|
"files": {
|
||||||
"phrases": "phrases.tsv"
|
"phrases": "phrases.tsv"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
// AppURLHandler.swift
|
||||||
|
// OSGKeyboard · Main App
|
||||||
|
//
|
||||||
|
// Captures `sourceApplication` from UIKit open-URL options (scheme D).
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import OSGKeyboardShared
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let osgKeyboardOpenURL = Notification.Name("osgkeyboard.openURL")
|
||||||
|
}
|
||||||
|
|
||||||
|
final class AppURLHandler: NSObject, UIApplicationDelegate {
|
||||||
|
func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
open url: URL,
|
||||||
|
options: [UIApplication.OpenURLOptionsKey: Any] = [:]
|
||||||
|
) -> Bool {
|
||||||
|
if let source = options[.sourceApplication] as? String {
|
||||||
|
FlowSessionBridge.setPendingHostBundleId(source)
|
||||||
|
}
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .osgKeyboardOpenURL,
|
||||||
|
object: nil,
|
||||||
|
userInfo: ["url": url]
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,14 @@
|
|||||||
// FlowDiagnostics.swift
|
// FlowDiagnostics.swift
|
||||||
// OSGKeyboard · Main App
|
// OSGKeyboard · Main App
|
||||||
//
|
//
|
||||||
// Structured logging for the Flow dictation pipeline. Visible in Xcode
|
// Structured logging for the Flow dictation pipeline. Delegates to
|
||||||
// console (DEBUG) and Console.app via `subsystem: com.osgkeyboard.ios`.
|
// `OSGLog.flow` for Console.app visibility.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import os
|
import OSGKeyboardShared
|
||||||
|
|
||||||
enum FlowDiagnostics {
|
enum FlowDiagnostics {
|
||||||
private static let logger = Logger(
|
|
||||||
subsystem: "com.osgkeyboard.ios",
|
|
||||||
category: "Flow"
|
|
||||||
)
|
|
||||||
|
|
||||||
static func log(_ message: String) {
|
static func log(_ message: String) {
|
||||||
logger.info("\(message, privacy: .public)")
|
OSGLog.flow.info("\(message, privacy: .public)")
|
||||||
#if DEBUG
|
|
||||||
print("🌊[OSGFlow] \(message)")
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// FlowLiveActivityController.swift
|
||||||
|
// OSGKeyboard · Main App
|
||||||
|
//
|
||||||
|
// Starts and updates the Flow Live Activity so the Dynamic Island shows the
|
||||||
|
// OSGKeyboard brand mark while a voice session is active.
|
||||||
|
|
||||||
|
import ActivityKit
|
||||||
|
import Foundation
|
||||||
|
import OSGKeyboardShared
|
||||||
|
|
||||||
|
enum FlowLiveActivityController {
|
||||||
|
nonisolated(unsafe) private static var currentActivity: Activity<FlowActivityAttributes>?
|
||||||
|
|
||||||
|
/// If the host app is force-quit its `endSession()` never runs, orphaning
|
||||||
|
/// the Live Activity. A `staleDate` lets the system grey it out and become
|
||||||
|
/// willing to reclaim it without our process — refreshed on every update
|
||||||
|
/// so a genuinely active, in-use session never looks stale.
|
||||||
|
private static let staleWindow: TimeInterval = 60 * 60
|
||||||
|
|
||||||
|
private static func freshContent(
|
||||||
|
phase: FlowActivityAttributes.ContentState.Phase
|
||||||
|
) -> ActivityContent<FlowActivityAttributes.ContentState> {
|
||||||
|
ActivityContent(
|
||||||
|
state: FlowActivityAttributes.ContentState(phase: phase),
|
||||||
|
staleDate: Date().addingTimeInterval(staleWindow)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Begin showing OSGKeyboard in the Dynamic Island for an active Flow session.
|
||||||
|
static func startSession() {
|
||||||
|
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
|
||||||
|
FlowDiagnostics.log("Live Activity disabled in Settings")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
endStaleActivities()
|
||||||
|
|
||||||
|
guard currentActivity == nil else {
|
||||||
|
update(phase: .idle)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
currentActivity = try Activity.request(
|
||||||
|
attributes: FlowActivityAttributes(),
|
||||||
|
content: freshContent(phase: .idle),
|
||||||
|
pushType: nil
|
||||||
|
)
|
||||||
|
FlowDiagnostics.log("Live Activity started")
|
||||||
|
} catch {
|
||||||
|
FlowDiagnostics.log("Live Activity start failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func update(phase: FlowActivityAttributes.ContentState.Phase) {
|
||||||
|
guard let activity = currentActivity else { return }
|
||||||
|
let content = freshContent(phase: phase)
|
||||||
|
Task {
|
||||||
|
await activity.update(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dismiss the island presentation when the Flow session ends.
|
||||||
|
static func endSession() {
|
||||||
|
guard let activity = currentActivity else {
|
||||||
|
endStaleActivities()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentActivity = nil
|
||||||
|
Task {
|
||||||
|
await activity.end(nil, dismissalPolicy: .immediate)
|
||||||
|
FlowDiagnostics.log("Live Activity ended")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host relaunch can leave orphan activities; clear them before starting anew.
|
||||||
|
private static func endStaleActivities() {
|
||||||
|
let staleActivities = Activity<FlowActivityAttributes>.activities
|
||||||
|
currentActivity = nil
|
||||||
|
guard !staleActivities.isEmpty else { return }
|
||||||
|
Task {
|
||||||
|
for activity in staleActivities {
|
||||||
|
await activity.end(nil, dismissalPolicy: .immediate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,12 +19,12 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
@Published private(set) var sessionExpiresAt: Date?
|
@Published private(set) var sessionExpiresAt: Date?
|
||||||
/// Non-nil when continuous capture failed or permissions are missing.
|
/// Non-nil when continuous capture failed or permissions are missing.
|
||||||
@Published private(set) var sessionWarning: String?
|
@Published private(set) var sessionWarning: String?
|
||||||
|
/// Cold-start handoff overlay state (scheme B).
|
||||||
|
@Published var coldStartContext: FlowColdStartContext?
|
||||||
|
|
||||||
private let capture = FlowContinuousCapture()
|
private let capture = FlowContinuousCapture()
|
||||||
private let store = AppGroupStore()
|
private let store = AppGroupStore()
|
||||||
/// Cloud-engine polish; local engine now ALSO runs through the
|
/// Cloud-engine polish; local engine runs through built-in DeepSeek polish.
|
||||||
/// polisher when `localModeCloudPolishEnabled` is on — the same
|
|
||||||
/// `PolishingService` short-circuits to raw when the toggle is off.
|
|
||||||
private var polisher: PolishingService {
|
private var polisher: PolishingService {
|
||||||
PolishingService()
|
PolishingService()
|
||||||
}
|
}
|
||||||
@@ -33,12 +33,13 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
/// factory-built service straight back without going through the
|
/// factory-built service straight back without going through the
|
||||||
/// old `OnDeviceModelWarmup` registry.
|
/// old `OnDeviceModelWarmup` registry.
|
||||||
private var sessionASR: ASRService?
|
private var sessionASR: ASRService?
|
||||||
|
/// Tracks which engine mode `sessionASR` was created for.
|
||||||
|
private var sessionASREngineMode: String?
|
||||||
|
/// Locale id last passed to `warmup(locale:)`.
|
||||||
|
private var sessionASRWarmedLocaleID: String?
|
||||||
private var asr: ASRService {
|
private var asr: ASRService {
|
||||||
if let sessionASR { return sessionASR }
|
if let sessionASR { return sessionASR }
|
||||||
let service = ASRServiceFactory.make(
|
let service = ASRServiceFactory.make()
|
||||||
engineMode: store.engineMode,
|
|
||||||
localBackend: store.localASRBackend
|
|
||||||
)
|
|
||||||
sessionASR = service
|
sessionASR = service
|
||||||
return service
|
return service
|
||||||
}
|
}
|
||||||
@@ -62,108 +63,77 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
/// True while the host app scene is `.active` — drives foreground renewal.
|
/// True while the host app scene is `.active` — drives foreground renewal.
|
||||||
private var isAppForeground = false
|
private var isAppForeground = false
|
||||||
private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
|
private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
|
||||||
|
/// True while handling a keyboard-initiated `startflow` cold start.
|
||||||
|
private var isColdStartHandoff = false
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
Task { @MainActor [weak self] in
|
// Sessions are (re)started explicitly on app foreground via
|
||||||
await self?.bootstrapFromStorageIfNeeded()
|
// `activateOnForeground()`. We deliberately do NOT silently reattach a
|
||||||
}
|
// stored session here — after a force-quit that would resurrect capture
|
||||||
|
// (and keep a stale Live Activity alive) without the user re-opening.
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public
|
// MARK: - Public
|
||||||
|
|
||||||
/// Starts a Flow session: permissions → continuous capture → App Group active.
|
/// Starts a Flow session: permissions → continuous capture → App Group active.
|
||||||
func startSession(duration: TimeInterval = FlowSessionKeys.defaultSessionDuration) {
|
func startSession(duration: TimeInterval? = nil, coldStart: Bool = false) {
|
||||||
guard AppGroup.isAvailable else {
|
guard AppGroup.isAvailable else {
|
||||||
debug("cannot start flow session: App Group unavailable")
|
debug("cannot start flow session: App Group unavailable")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if coldStart {
|
||||||
|
isColdStartHandoff = true
|
||||||
|
}
|
||||||
|
|
||||||
if isActive {
|
if isActive {
|
||||||
extendSession(duration: duration)
|
extendSession(duration: duration)
|
||||||
return
|
if coldStart {
|
||||||
}
|
Task { @MainActor [weak self] in
|
||||||
|
self?.handleColdStartAfterSessionReady()
|
||||||
startTask?.cancel()
|
}
|
||||||
startTask = Task { @MainActor [weak self] in
|
|
||||||
await self?.startSessionAsync(duration: duration)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Called on launch / foreground when onboarding is complete.
|
|
||||||
func autoStartIfNeeded() {
|
|
||||||
guard AppGroup.isAvailable else { return }
|
|
||||||
guard !isActive, !isStarting else { return }
|
|
||||||
|
|
||||||
guard AppPermissions.flowRequirementsMet else {
|
|
||||||
sessionWarning = permissionWarningMessage()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let markedActive = AppGroup.defaults.bool(forKey: FlowSessionKeys.flowSessionActive)
|
|
||||||
if markedActive, FlowSessionBridge.remainingSessionDuration() != nil {
|
|
||||||
Task { await bootstrapFromStorageIfNeeded() }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
startSession()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reattach capture when the host app was killed but the session has not expired.
|
|
||||||
func bootstrapFromStorageIfNeeded() async {
|
|
||||||
guard AppGroup.isAvailable, !isActive else { return }
|
|
||||||
|
|
||||||
let markedActive = AppGroup.defaults.bool(forKey: FlowSessionKeys.flowSessionActive)
|
|
||||||
guard markedActive, let remaining = FlowSessionBridge.remainingSessionDuration(), remaining > 0 else {
|
|
||||||
if markedActive {
|
|
||||||
FlowSessionBridge.markSessionInactive()
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard !isStarting else { return }
|
||||||
|
|
||||||
|
startTask?.cancel()
|
||||||
|
startTask = Task { @MainActor [weak self] in
|
||||||
|
await self?.startSessionAsync(duration: duration)
|
||||||
|
self?.handleColdStartAfterSessionReady()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auto-start (or renew) the Flow session on every app foreground when
|
||||||
|
/// permissions allow — the "always auto-open, no off switch" policy. Also
|
||||||
|
/// clears any orphaned Live Activity a previously force-quit process left
|
||||||
|
/// behind (its `endSession()` could not run at kill time).
|
||||||
|
func activateOnForeground() {
|
||||||
|
guard AppGroup.isAvailable else { return }
|
||||||
guard AppPermissions.flowRequirementsMet else {
|
guard AppPermissions.flowRequirementsMet else {
|
||||||
sessionWarning = permissionWarningMessage()
|
sessionWarning = permissionWarningMessage()
|
||||||
|
FlowLiveActivityController.endSession()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
startSession()
|
||||||
|
}
|
||||||
|
|
||||||
isStarting = true
|
func dismissColdStartOverlay() {
|
||||||
sessionWarning = nil
|
coldStartContext = nil
|
||||||
defer { isStarting = false }
|
isColdStartHandoff = false
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
func returnToPendingHostFromColdStart() {
|
||||||
try capture.start()
|
_ = HostReturnService.openPendingHostIfPossible()
|
||||||
} catch {
|
dismissColdStartOverlay()
|
||||||
let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
|
||||||
sessionWarning = message
|
|
||||||
FlowSessionBridge.markSessionInactive()
|
|
||||||
debug("bootstrap capture failed: \(message)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
FlowSessionBridge.writeHeartbeat()
|
|
||||||
FlowSessionDarwin.postSessionChanged()
|
|
||||||
isActive = true
|
|
||||||
ScreenWakeLock.acquire()
|
|
||||||
if let expires = FlowSessionBridge.sessionExpiresAt() {
|
|
||||||
sessionExpiresAt = Date(timeIntervalSince1970: expires)
|
|
||||||
}
|
|
||||||
|
|
||||||
startHeartbeat()
|
|
||||||
startPolling()
|
|
||||||
startLevelPublishing()
|
|
||||||
scheduleExpiry(after: remaining)
|
|
||||||
|
|
||||||
// v0.2.0: iOS `SpeechAnalyzer` needs no warm-up. We still
|
|
||||||
// re-bind the cached `sessionASR` so a config flip mid-session
|
|
||||||
// (e.g. switching from cloud to local) is honoured.
|
|
||||||
bindSessionASR()
|
|
||||||
|
|
||||||
debug("Flow session restored (\(Int(remaining))s remaining)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func endSession() {
|
func endSession() {
|
||||||
guard isActive else { return }
|
guard isActive else { return }
|
||||||
debug("Flow session ended")
|
debug("Flow session ended")
|
||||||
|
|
||||||
|
dismissColdStartOverlay()
|
||||||
startTask?.cancel()
|
startTask?.cancel()
|
||||||
startTask = nil
|
startTask = nil
|
||||||
pollingTask?.cancel()
|
pollingTask?.cancel()
|
||||||
@@ -192,8 +162,11 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
endBackgroundKeepAlive()
|
endBackgroundKeepAlive()
|
||||||
ScreenWakeLock.release()
|
ScreenWakeLock.release()
|
||||||
sessionASR = nil
|
sessionASR = nil
|
||||||
|
sessionASREngineMode = nil
|
||||||
|
sessionASRWarmedLocaleID = nil
|
||||||
FlowSessionBridge.markSessionInactive()
|
FlowSessionBridge.markSessionInactive()
|
||||||
FlowSessionDarwin.postSessionChanged()
|
FlowSessionDarwin.postSessionChanged()
|
||||||
|
FlowLiveActivityController.endSession()
|
||||||
isActive = false
|
isActive = false
|
||||||
sessionExpiresAt = nil
|
sessionExpiresAt = nil
|
||||||
sessionWarning = nil
|
sessionWarning = nil
|
||||||
@@ -201,31 +174,27 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
lastFinal = ""
|
lastFinal = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func extendSession(duration: TimeInterval = FlowSessionKeys.defaultSessionDuration) {
|
func extendSession(duration: TimeInterval? = nil) {
|
||||||
FlowSessionBridge.extendSession(by: duration)
|
let resolved = duration ?? FlowSessionPolicy.sessionDuration()
|
||||||
sessionExpiresAt = Date().addingTimeInterval(duration)
|
FlowSessionBridge.extendSession(by: resolved)
|
||||||
scheduleExpiry(after: duration)
|
sessionExpiresAt = Date().addingTimeInterval(resolved)
|
||||||
|
scheduleExpiry(after: resolved)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called from `OSGKeyboardApp` when `scenePhase` changes.
|
/// Called from `OSGKeyboardApp` when `scenePhase` changes.
|
||||||
func setAppForeground(_ foreground: Bool) {
|
func setAppForeground(_ foreground: Bool) {
|
||||||
isAppForeground = foreground
|
isAppForeground = foreground
|
||||||
if foreground, isActive {
|
|
||||||
renewSessionIfNeededWhileForeground()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full scene lifecycle — keeps Flow + ASR alive across app switches.
|
/// Full scene lifecycle — keeps Flow + ASR alive across app switches.
|
||||||
func handleScenePhase(_ phase: ScenePhase) {
|
func handleScenePhase(_ phase: ScenePhase) {
|
||||||
switch phase {
|
switch phase {
|
||||||
case .active:
|
case .active:
|
||||||
FlowAppLifecycle.shared.setForeground(true)
|
|
||||||
setAppForeground(true)
|
setAppForeground(true)
|
||||||
resumeAfterForeground()
|
resumeAfterForeground()
|
||||||
case .inactive:
|
case .inactive:
|
||||||
writeHeartbeatIfActive()
|
writeHeartbeatIfActive()
|
||||||
case .background:
|
case .background:
|
||||||
FlowAppLifecycle.shared.setForeground(false)
|
|
||||||
setAppForeground(false)
|
setAppForeground(false)
|
||||||
beginBackgroundKeepAlive()
|
beginBackgroundKeepAlive()
|
||||||
@unknown default:
|
@unknown default:
|
||||||
@@ -269,7 +238,8 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
await self?.reactivateCaptureIfNeeded()
|
await self?.reactivateCaptureIfNeeded()
|
||||||
// v0.2.0: iOS `SpeechAnalyzer` is bundled with the OS; no
|
// v0.2.0: iOS `SpeechAnalyzer` is bundled with the OS; no
|
||||||
// on-device weights to reload after a background trip.
|
// on-device weights to reload after a background trip.
|
||||||
self?.bindSessionASR()
|
self?.bindSessionASRIfNeeded()
|
||||||
|
self?.scheduleASRWarmup()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,25 +261,29 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extend the session before it expires while the host app stays in foreground.
|
/// Extend expiry after utterance completion based on the inactivity policy.
|
||||||
private func renewSessionIfNeededWhileForeground() {
|
private func touchSessionActivity() {
|
||||||
guard isActive, isAppForeground else { return }
|
guard isActive else { return }
|
||||||
guard let remaining = FlowSessionBridge.remainingSessionDuration() else { return }
|
FlowSessionBridge.touchLastActivity()
|
||||||
let threshold = FlowSessionKeys.defaultSessionDuration * 0.25
|
if let expires = FlowSessionBridge.sessionExpiresAt() {
|
||||||
guard remaining < threshold else { return }
|
sessionExpiresAt = Date(timeIntervalSince1970: expires)
|
||||||
extendSession()
|
let remaining = expires - Date().timeIntervalSince1970
|
||||||
debug("Flow session renewed in foreground (\(Int(threshold))s threshold)")
|
if remaining > 0 {
|
||||||
|
scheduleExpiry(after: remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Session start
|
// MARK: - Session start
|
||||||
|
|
||||||
private func startSessionAsync(duration: TimeInterval) async {
|
private func startSessionAsync(duration: TimeInterval?) async {
|
||||||
isStarting = true
|
isStarting = true
|
||||||
sessionWarning = nil
|
sessionWarning = nil
|
||||||
defer { isStarting = false }
|
defer { isStarting = false }
|
||||||
|
|
||||||
guard AppPermissions.flowRequirementsMet else {
|
guard AppPermissions.flowRequirementsMet else {
|
||||||
sessionWarning = permissionWarningMessage()
|
sessionWarning = permissionWarningMessage()
|
||||||
|
isColdStartHandoff = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,36 +292,77 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
} catch {
|
} catch {
|
||||||
let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
||||||
sessionWarning = message
|
sessionWarning = message
|
||||||
|
isColdStartHandoff = false
|
||||||
debug("continuous capture failed: \(message)")
|
debug("continuous capture failed: \(message)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
FlowSessionBridge.markSessionActive(duration: duration)
|
let resolvedDuration = duration ?? FlowSessionPolicy.sessionDuration()
|
||||||
|
FlowSessionBridge.markSessionActive(duration: resolvedDuration)
|
||||||
FlowSessionDarwin.postSessionChanged()
|
FlowSessionDarwin.postSessionChanged()
|
||||||
isActive = true
|
isActive = true
|
||||||
ScreenWakeLock.acquire()
|
ScreenWakeLock.acquire()
|
||||||
sessionExpiresAt = Date().addingTimeInterval(duration)
|
sessionExpiresAt = Date().addingTimeInterval(resolvedDuration)
|
||||||
|
|
||||||
startHeartbeat()
|
startHeartbeat()
|
||||||
startPolling()
|
startPolling()
|
||||||
startLevelPublishing()
|
startLevelPublishing()
|
||||||
scheduleExpiry(after: duration)
|
scheduleExpiry(after: resolvedDuration)
|
||||||
|
|
||||||
// v0.2.0: iOS `SpeechAnalyzer` needs no warm-up; just refresh
|
bindSessionASRIfNeeded()
|
||||||
// the cached ASR service in case the user flipped engines
|
scheduleASRWarmup()
|
||||||
// while the session was idle.
|
FlowLiveActivityController.startSession()
|
||||||
bindSessionASR()
|
|
||||||
|
|
||||||
debug("Flow session started (\(Int(duration))s), continuous capture running")
|
debug("Flow session started (\(Int(resolvedDuration))s inactivity window), continuous capture running")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func bindSessionASR() {
|
@MainActor
|
||||||
sessionASR = ASRServiceFactory.make(
|
private func handleColdStartAfterSessionReady() {
|
||||||
engineMode: store.engineMode,
|
guard isColdStartHandoff, isActive else { return }
|
||||||
localBackend: store.localASRBackend
|
|
||||||
|
let hostEntry = HostReturnService.pendingHostEntry()
|
||||||
|
let skipSwitch = FlowSessionPolicy.skipAppSwitch()
|
||||||
|
|
||||||
|
if skipSwitch, hostEntry != nil, HostReturnService.openPendingHostIfPossible() {
|
||||||
|
dismissColdStartOverlay()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
coldStartContext = FlowColdStartContext(
|
||||||
|
hostEntry: hostEntry,
|
||||||
|
showReturnAlert: hostEntry != nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func bindSessionASRIfNeeded(force: Bool = false) {
|
||||||
|
let engineMode = store.engineMode
|
||||||
|
if !force,
|
||||||
|
let sessionASR,
|
||||||
|
sessionASREngineMode == engineMode {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessionASR?.cancel()
|
||||||
|
sessionASR = ASRServiceFactory.make()
|
||||||
|
sessionASREngineMode = engineMode
|
||||||
|
sessionASRWarmedLocaleID = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleASRWarmup() {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
await self?.warmupASRIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func warmupASRIfNeeded() async {
|
||||||
|
bindSessionASRIfNeeded()
|
||||||
|
let locale = SpeechLocaleResolver.resolve(store.localeId)
|
||||||
|
let localeID = locale.identifier(.bcp47)
|
||||||
|
guard sessionASRWarmedLocaleID != localeID else { return }
|
||||||
|
await asr.warmup(locale: locale)
|
||||||
|
sessionASRWarmedLocaleID = localeID
|
||||||
|
FlowDiagnostics.log("ASR warmup complete locale=\(localeID)")
|
||||||
|
}
|
||||||
|
|
||||||
private func permissionWarningMessage() -> String {
|
private func permissionWarningMessage() -> String {
|
||||||
if AppPermissions.micStatus != .granted {
|
if AppPermissions.micStatus != .granted {
|
||||||
return AppL10n.string("flow.error.micRequired")
|
return AppL10n.string("flow.error.micRequired")
|
||||||
@@ -384,7 +399,10 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
|
|
||||||
private func beginUtterance() {
|
private func beginUtterance() {
|
||||||
guard capture.running else {
|
guard capture.running else {
|
||||||
failUtterance(message: AppL10n.string("flow.error.audioUnavailable"))
|
failUtterance(
|
||||||
|
message: AppL10n.string("flow.error.audioUnavailable"),
|
||||||
|
kind: .audioUnavailable
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard !isUtteranceProcessing else {
|
guard !isUtteranceProcessing else {
|
||||||
@@ -392,8 +410,8 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Honor engine / ASR backend changes without restarting the session.
|
// Usually already warm from session start; refresh without blocking the mic gate.
|
||||||
bindSessionASR()
|
scheduleASRWarmup()
|
||||||
|
|
||||||
currentPartial = ""
|
currentPartial = ""
|
||||||
lastFinal = ""
|
lastFinal = ""
|
||||||
@@ -410,8 +428,9 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
|
|
||||||
isUtteranceRecording = true
|
isUtteranceRecording = true
|
||||||
utteranceRecordingStartedAt = Date()
|
utteranceRecordingStartedAt = Date()
|
||||||
|
FlowLiveActivityController.update(phase: .recording)
|
||||||
FlowDiagnostics.log(
|
FlowDiagnostics.log(
|
||||||
"beginUtterance engine=\(store.engineMode) asr=\(store.localASRBackend.rawValue) " +
|
"beginUtterance engine=\(store.engineMode) " +
|
||||||
"asrType=\(type(of: asr)) pipelined=true max=\(Int(FlowSessionKeys.maxUtteranceDuration))s"
|
"asrType=\(type(of: asr)) pipelined=true max=\(Int(FlowSessionKeys.maxUtteranceDuration))s"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -419,6 +438,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
let outcome = await pipeline.transcribe(stream: stream) { partial in
|
let outcome = await pipeline.transcribe(stream: stream) { partial in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
manager?.currentPartial = partial
|
manager?.currentPartial = partial
|
||||||
|
FlowSessionBridge.storeTranscriptionPartial(partial)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Re-bind `manager` inside the `@MainActor` block so the
|
// Re-bind `manager` inside the `@MainActor` block so the
|
||||||
@@ -439,9 +459,9 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
case .failure(let message):
|
case .failure(let message):
|
||||||
manager.debug("asr error: \(message)")
|
manager.debug("asr error: \(message)")
|
||||||
if manager.isUtteranceRecording {
|
if manager.isUtteranceRecording {
|
||||||
manager.failUtterance(message: message)
|
manager.failUtterance(message: message, kind: .asrFailed)
|
||||||
} else if manager.isUtteranceProcessing {
|
} else if manager.isUtteranceProcessing {
|
||||||
manager.finishProcessing(withError: message)
|
manager.finishProcessing(withError: message, kind: .asrFailed)
|
||||||
}
|
}
|
||||||
case .cancelled:
|
case .cancelled:
|
||||||
break
|
break
|
||||||
@@ -461,6 +481,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
FlowSessionBridge.setRecordingState(.processing)
|
FlowSessionBridge.setRecordingState(.processing)
|
||||||
isUtteranceRecording = false
|
isUtteranceRecording = false
|
||||||
isUtteranceProcessing = true
|
isUtteranceProcessing = true
|
||||||
|
FlowLiveActivityController.update(phase: .processing)
|
||||||
|
|
||||||
// Do NOT cancel `asrTask` or `asr` — the preview pipeline relies on
|
// Do NOT cancel `asrTask` or `asr` — the preview pipeline relies on
|
||||||
// the consumer staying alive until `.final` lands (see
|
// the consumer staying alive until `.final` lands (see
|
||||||
@@ -487,11 +508,16 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
currentPartial = ""
|
currentPartial = ""
|
||||||
lastFinal = ""
|
lastFinal = ""
|
||||||
chunkWarnings = []
|
chunkWarnings = []
|
||||||
|
FlowSessionBridge.storeTranscriptionPartial("")
|
||||||
FlowSessionBridge.setRecordingState(.idle)
|
FlowSessionBridge.setRecordingState(.idle)
|
||||||
|
FlowLiveActivityController.update(phase: .idle)
|
||||||
debug("utterance aborted")
|
debug("utterance aborted")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func failUtterance(message: String) {
|
private func failUtterance(
|
||||||
|
message: String,
|
||||||
|
kind: FlowSessionKeys.TranscriptionErrorKind = .asrFailed
|
||||||
|
) {
|
||||||
isUtteranceRecording = false
|
isUtteranceRecording = false
|
||||||
isUtteranceProcessing = false
|
isUtteranceProcessing = false
|
||||||
utteranceRecordingStartedAt = nil
|
utteranceRecordingStartedAt = nil
|
||||||
@@ -505,12 +531,17 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
currentPartial = ""
|
currentPartial = ""
|
||||||
lastFinal = ""
|
lastFinal = ""
|
||||||
chunkWarnings = []
|
chunkWarnings = []
|
||||||
FlowSessionBridge.storeTranscriptionError(message)
|
FlowSessionBridge.storeTranscriptionPartial("")
|
||||||
|
FlowSessionBridge.storeTranscriptionError(message, kind: kind)
|
||||||
FlowSessionBridge.setRecordingState(.idle)
|
FlowSessionBridge.setRecordingState(.idle)
|
||||||
|
FlowLiveActivityController.update(phase: .idle)
|
||||||
debug("utterance failed: \(message)")
|
debug("utterance failed: \(message)")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func finishProcessing(withError message: String) {
|
private func finishProcessing(
|
||||||
|
withError message: String,
|
||||||
|
kind: FlowSessionKeys.TranscriptionErrorKind = .asrFailed
|
||||||
|
) {
|
||||||
isUtteranceProcessing = false
|
isUtteranceProcessing = false
|
||||||
utteranceRecordingStartedAt = nil
|
utteranceRecordingStartedAt = nil
|
||||||
finalizeTask?.cancel()
|
finalizeTask?.cancel()
|
||||||
@@ -519,8 +550,10 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
currentPartial = ""
|
currentPartial = ""
|
||||||
lastFinal = ""
|
lastFinal = ""
|
||||||
chunkWarnings = []
|
chunkWarnings = []
|
||||||
FlowSessionBridge.storeTranscriptionError(message)
|
FlowSessionBridge.storeTranscriptionPartial("")
|
||||||
|
FlowSessionBridge.storeTranscriptionError(message, kind: kind)
|
||||||
FlowSessionBridge.setRecordingState(.idle)
|
FlowSessionBridge.setRecordingState(.idle)
|
||||||
|
FlowLiveActivityController.update(phase: .idle)
|
||||||
debug("utterance processing failed: \(message)")
|
debug("utterance processing failed: \(message)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,12 +562,13 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
defer {
|
defer {
|
||||||
isUtteranceProcessing = false
|
isUtteranceProcessing = false
|
||||||
FlowSessionBridge.setRecordingState(.idle)
|
FlowSessionBridge.setRecordingState(.idle)
|
||||||
|
FlowLiveActivityController.update(phase: .idle)
|
||||||
|
touchSessionActivity()
|
||||||
}
|
}
|
||||||
|
|
||||||
let asrWait = asrWaitTimeout()
|
let asrWait = asrWaitTimeout()
|
||||||
FlowDiagnostics.log(
|
FlowDiagnostics.log(
|
||||||
"finalize start asrWait=\(Int(asrWait))s engine=\(store.engineMode) " +
|
"finalize start asrWait=\(Int(asrWait))s engine=\(store.engineMode)"
|
||||||
"backend=\(store.localASRBackend.rawValue)"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
let asrDeadline = Date().addingTimeInterval(asrWait)
|
let asrDeadline = Date().addingTimeInterval(asrWait)
|
||||||
@@ -560,10 +594,13 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
let key = (asrTask?.isCancelled == true)
|
let key = (asrTask?.isCancelled == true)
|
||||||
? "flow.error.recognitionInterrupted"
|
? "flow.error.recognitionInterrupted"
|
||||||
: "flow.error.noSpeech"
|
: "flow.error.noSpeech"
|
||||||
|
let kind: FlowSessionKeys.TranscriptionErrorKind =
|
||||||
|
(asrTask?.isCancelled == true) ? .recognitionInterrupted : .noSpeech
|
||||||
FlowDiagnostics.log("finalize failed: empty transcript after \(String(format: "%.1f", asrElapsed))s")
|
FlowDiagnostics.log("finalize failed: empty transcript after \(String(format: "%.1f", asrElapsed))s")
|
||||||
utteranceRecordingStartedAt = nil
|
utteranceRecordingStartedAt = nil
|
||||||
FlowSessionBridge.storeTranscriptionError(
|
FlowSessionBridge.storeTranscriptionError(
|
||||||
AppL10n.string(key)
|
AppL10n.string(key),
|
||||||
|
kind: kind
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -576,33 +613,12 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
// from the keyboard extension are visible before polish/translate.
|
// from the keyboard extension are visible before polish/translate.
|
||||||
let pipelineStore = AppGroupStore()
|
let pipelineStore = AppGroupStore()
|
||||||
|
|
||||||
if !pipelineStore.shouldRunCloudLLMStep {
|
|
||||||
// Local engine with cloud polish off — ASR-only.
|
|
||||||
FlowSessionBridge.storeTranscriptionResult(text, polishWarning: chunkNote)
|
|
||||||
FlowDiagnostics.log(
|
|
||||||
"finalize ASR-only total=\(String(format: "%.1f", Date().timeIntervalSince(pipelineStarted)))s " +
|
|
||||||
"len=\(text.count)"
|
|
||||||
)
|
|
||||||
SpeechHistoryStore.shared.recordUtterance(
|
|
||||||
text: text,
|
|
||||||
engineMode: engineMode,
|
|
||||||
duration: recordingDuration,
|
|
||||||
wasTranslation: false
|
|
||||||
)
|
|
||||||
currentPartial = ""
|
|
||||||
lastFinal = ""
|
|
||||||
chunkWarnings = []
|
|
||||||
debug("utterance finalized length=\(text.count)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var delivered = text
|
var delivered = text
|
||||||
let polishStarted = Date()
|
let polishStarted = Date()
|
||||||
let polishMode = pipelineStore.polishModeForPipeline
|
let polishMode = pipelineStore.polishModeForPipeline
|
||||||
FlowDiagnostics.log(
|
FlowDiagnostics.log(
|
||||||
"finalize LLM mode=\(Self.polishModeLogLabel(polishMode)) " +
|
"finalize LLM mode=\(Self.polishModeLogLabel(polishMode)) " +
|
||||||
"translationTarget=\(pipelineStore.translationTargetLocaleId) " +
|
"translationTarget=\(pipelineStore.translationTargetLocaleId)"
|
||||||
"cloudPolish=\(pipelineStore.localModeCloudPolishEnabled)"
|
|
||||||
)
|
)
|
||||||
do {
|
do {
|
||||||
let polished = try await polisher.polish(
|
let polished = try await polisher.polish(
|
||||||
@@ -640,6 +656,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
currentPartial = ""
|
currentPartial = ""
|
||||||
lastFinal = ""
|
lastFinal = ""
|
||||||
chunkWarnings = []
|
chunkWarnings = []
|
||||||
|
FlowSessionBridge.storeTranscriptionPartial("")
|
||||||
chunkedPipeline = nil
|
chunkedPipeline = nil
|
||||||
debug("utterance finalized length=\(text.count)")
|
debug("utterance finalized length=\(text.count)")
|
||||||
}
|
}
|
||||||
@@ -712,7 +729,6 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
heartbeatTask = Task { @MainActor [weak self] in
|
heartbeatTask = Task { @MainActor [weak self] in
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
FlowSessionBridge.writeHeartbeat()
|
FlowSessionBridge.writeHeartbeat()
|
||||||
self?.renewSessionIfNeededWhileForeground()
|
|
||||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
guard self?.isActive == true else { break }
|
guard self?.isActive == true else { break }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// HostReturnService.swift
|
||||||
|
// OSGKeyboard · Main App
|
||||||
|
//
|
||||||
|
// Opens a whitelisted host-app URL after a cold-start Flow handoff.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import OSGKeyboardShared
|
||||||
|
|
||||||
|
enum HostReturnService {
|
||||||
|
/// Attempts to return to the pending host app. Clears the pending bundle id on success.
|
||||||
|
@MainActor
|
||||||
|
static func openPendingHostIfPossible() -> Bool {
|
||||||
|
let bundleId = FlowSessionBridge.pendingHostBundleId()
|
||||||
|
guard let entry = HostAppURLRegistry.lookup(bundleId: bundleId),
|
||||||
|
let url = entry.returnURL else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard UIApplication.shared.canOpenURL(url) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
UIApplication.shared.open(url, options: [:]) { success in
|
||||||
|
if success {
|
||||||
|
FlowSessionBridge.clearPendingHostBundleId()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
static func openHost(entry: HostAppEntry) -> Bool {
|
||||||
|
guard let url = entry.returnURL, UIApplication.shared.canOpenURL(url) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
UIApplication.shared.open(url, options: [:]) { success in
|
||||||
|
if success {
|
||||||
|
FlowSessionBridge.clearPendingHostBundleId()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
static func pendingHostEntry() -> HostAppEntry? {
|
||||||
|
HostAppURLRegistry.lookup(bundleId: FlowSessionBridge.pendingHostBundleId())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
// DictationCaptureView.swift
|
|
||||||
// OSGKeyboard · Main App
|
|
||||||
//
|
|
||||||
// Host-app recording surface for keyboard handoff.
|
|
||||||
// Uses the shared `LiveDictationController` — the same entry point as
|
|
||||||
// the keyboard preview sheet.
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class DictationSessionCoordinator: ObservableObject {
|
|
||||||
@Published var isPresenting: Bool = false
|
|
||||||
|
|
||||||
func present() {
|
|
||||||
isPresenting = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func dismiss() {
|
|
||||||
isPresenting = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DictationCaptureView: View {
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
|
||||||
|
|
||||||
@ObservedObject var config: ProviderConfig
|
|
||||||
@ObservedObject var coordinator: DictationSessionCoordinator
|
|
||||||
@StateObject private var dictation = LiveDictationController()
|
|
||||||
|
|
||||||
@State private var statusText: String = ""
|
|
||||||
@State private var isSaving: Bool = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
palette.background.ignoresSafeArea()
|
|
||||||
VStack(spacing: Spacing.lg) {
|
|
||||||
Spacer()
|
|
||||||
Image(systemName: "mic.circle.fill")
|
|
||||||
.font(.system(size: 84, weight: .light))
|
|
||||||
.foregroundStyle(palette.accent)
|
|
||||||
|
|
||||||
Text(titleText)
|
|
||||||
.font(TypeStyle.title2)
|
|
||||||
.foregroundStyle(palette.textPrimary)
|
|
||||||
|
|
||||||
Text(statusText)
|
|
||||||
.font(TypeStyle.caption)
|
|
||||||
.foregroundStyle(palette.textSecondary)
|
|
||||||
.multilineTextAlignment(.center)
|
|
||||||
.padding(.horizontal, Spacing.lg)
|
|
||||||
|
|
||||||
ProgressView(value: dictation.level, total: 1.0)
|
|
||||||
.tint(palette.accent)
|
|
||||||
.padding(.horizontal, Spacing.lg)
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
HStack(spacing: Spacing.sm) {
|
|
||||||
Button {
|
|
||||||
cancelAndClose()
|
|
||||||
} label: {
|
|
||||||
Text("common.cancel")
|
|
||||||
.secondaryButton()
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.disabled(isSaving)
|
|
||||||
|
|
||||||
Button {
|
|
||||||
stopAndFinalize()
|
|
||||||
} label: {
|
|
||||||
Text("common.done")
|
|
||||||
.primaryButton()
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.disabled(isSaving || dictation.phase != .recording)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, Spacing.md)
|
|
||||||
.padding(.bottom, Spacing.lg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
statusText = AppL10n.string("dictation.status.ready")
|
|
||||||
DictationBridge.setStatus(.requested)
|
|
||||||
startRecording()
|
|
||||||
}
|
|
||||||
.onDisappear {
|
|
||||||
dictation.stop()
|
|
||||||
}
|
|
||||||
.onChange(of: dictation.phase) { _, new in
|
|
||||||
switch new {
|
|
||||||
case .recording:
|
|
||||||
DictationBridge.setStatus(.recording)
|
|
||||||
statusText = config.isLocalEngine
|
|
||||||
? AppL10n.string("dictation.status.listeningLive")
|
|
||||||
: AppL10n.string("dictation.status.listening")
|
|
||||||
case .processing:
|
|
||||||
DictationBridge.setStatus(.transcribing)
|
|
||||||
statusText = AppL10n.string("dictation.status.processing")
|
|
||||||
case .requestingPermission:
|
|
||||||
statusText = AppL10n.string("dictation.status.requestingPermission")
|
|
||||||
case .denied(let message):
|
|
||||||
DictationBridge.setStatus(.error, message: message)
|
|
||||||
statusText = message
|
|
||||||
case .error(let message):
|
|
||||||
DictationBridge.setStatus(.error, message: message)
|
|
||||||
statusText = message
|
|
||||||
case .idle:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: dictation.currentPartial) { _, new in
|
|
||||||
guard config.isLocalEngine, !new.isEmpty else { return }
|
|
||||||
statusText = new
|
|
||||||
}
|
|
||||||
.onChange(of: dictation.lastFinal) { _, new in
|
|
||||||
guard !new.isEmpty else { return }
|
|
||||||
saveAndClose(new)
|
|
||||||
}
|
|
||||||
.onChange(of: config.uiLanguage) { _, _ in
|
|
||||||
refreshStatusForCurrentPhase()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var titleText: String {
|
|
||||||
isSaving
|
|
||||||
? AppL10n.string("dictation.status.saving")
|
|
||||||
: AppL10n.string("dictation.title")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startRecording() {
|
|
||||||
Task { await dictation.start(localeId: config.localeId) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stopAndFinalize() {
|
|
||||||
dictation.stop()
|
|
||||||
statusText = AppL10n.string("dictation.status.waitingResult")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func cancelAndClose() {
|
|
||||||
dictation.stop()
|
|
||||||
DictationBridge.setStatus(.cancelled)
|
|
||||||
coordinator.dismiss()
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func saveAndClose(_ transcript: String) {
|
|
||||||
guard !isSaving else { return }
|
|
||||||
isSaving = true
|
|
||||||
statusText = AppL10n.string("dictation.status.processing")
|
|
||||||
Task {
|
|
||||||
let delivered = transcript
|
|
||||||
DictationBridge.storePendingTranscript(delivered, polishWarning: nil)
|
|
||||||
coordinator.dismiss()
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func refreshStatusForCurrentPhase() {
|
|
||||||
switch dictation.phase {
|
|
||||||
case .idle:
|
|
||||||
statusText = AppL10n.string("dictation.status.ready")
|
|
||||||
case .recording:
|
|
||||||
statusText = config.isLocalEngine
|
|
||||||
? AppL10n.string("dictation.status.listeningLive")
|
|
||||||
: AppL10n.string("dictation.status.listening")
|
|
||||||
case .processing:
|
|
||||||
statusText = AppL10n.string("dictation.status.processing")
|
|
||||||
case .requestingPermission:
|
|
||||||
statusText = AppL10n.string("dictation.status.requestingPermission")
|
|
||||||
case .denied(let message), .error(let message):
|
|
||||||
statusText = message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// FlowColdStartOverlay.swift
|
||||||
|
// OSGKeyboard · Main App
|
||||||
|
//
|
||||||
|
// Minimal cold-start handoff UI: swipe-back guidance and optional return alert.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import OSGKeyboardShared
|
||||||
|
|
||||||
|
struct FlowColdStartContext: Equatable {
|
||||||
|
let hostEntry: HostAppEntry?
|
||||||
|
var showReturnAlert: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FlowColdStartOverlay: View {
|
||||||
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
|
|
||||||
|
let context: FlowColdStartContext
|
||||||
|
let onReturnToHost: () -> Void
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
@State private var showAlert: Bool
|
||||||
|
|
||||||
|
init(
|
||||||
|
context: FlowColdStartContext,
|
||||||
|
onReturnToHost: @escaping () -> Void,
|
||||||
|
onDismiss: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.context = context
|
||||||
|
self.onReturnToHost = onReturnToHost
|
||||||
|
self.onDismiss = onDismiss
|
||||||
|
_showAlert = State(initialValue: context.showReturnAlert)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
palette.background.opacity(0.96)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: Spacing.xl) {
|
||||||
|
Image("OSGBrandMark")
|
||||||
|
.renderingMode(.template)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 64, height: 64)
|
||||||
|
.foregroundStyle(palette.accent)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
|
||||||
|
Text("flow.coldStart.title")
|
||||||
|
.font(TypeStyle.title3)
|
||||||
|
.foregroundStyle(palette.textPrimary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Text(swipeHintKey)
|
||||||
|
.font(TypeStyle.body)
|
||||||
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, Spacing.lg)
|
||||||
|
|
||||||
|
swipeHintAnimation
|
||||||
|
.padding(.top, Spacing.md)
|
||||||
|
|
||||||
|
Button(action: onDismiss) {
|
||||||
|
Text("flow.coldStart.dismiss")
|
||||||
|
.font(TypeStyle.body.weight(.semibold))
|
||||||
|
.foregroundStyle(palette.accent)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, Spacing.md)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.horizontal, Spacing.xl)
|
||||||
|
.padding(.top, Spacing.lg)
|
||||||
|
}
|
||||||
|
.padding(Spacing.xl)
|
||||||
|
}
|
||||||
|
.alert(alertTitle, isPresented: $showAlert) {
|
||||||
|
if context.hostEntry != nil {
|
||||||
|
Button(returnButtonTitle, action: onReturnToHost)
|
||||||
|
}
|
||||||
|
Button("flow.coldStart.dismiss", role: .cancel, action: onDismiss)
|
||||||
|
} message: {
|
||||||
|
Text("flow.coldStart.alert.message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var swipeHintKey: LocalizedStringKey {
|
||||||
|
context.hostEntry == nil
|
||||||
|
? "flow.coldStart.swipeHint"
|
||||||
|
: "flow.coldStart.swipeHint.withSystemBack"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var alertTitle: String {
|
||||||
|
AppL10n.string("flow.coldStart.alert.title")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var returnButtonTitle: String {
|
||||||
|
guard let entry = context.hostEntry else {
|
||||||
|
return AppL10n.string("flow.coldStart.return.generic")
|
||||||
|
}
|
||||||
|
let appName = AppL10n.string(entry.displayNameKey)
|
||||||
|
return AppL10n.format("flow.coldStart.return.named", appName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var swipeHintAnimation: some View {
|
||||||
|
VStack(spacing: Spacing.sm) {
|
||||||
|
Image(systemName: "chevron.up")
|
||||||
|
.font(.system(size: 20, weight: .semibold))
|
||||||
|
.foregroundStyle(palette.textTertiary)
|
||||||
|
RoundedRectangle(cornerRadius: 3, style: .continuous)
|
||||||
|
.fill(palette.textTertiary.opacity(0.5))
|
||||||
|
.frame(width: 120, height: 5)
|
||||||
|
}
|
||||||
|
.accessibilityLabel(AppL10n.string("flow.coldStart.swipeAccessibility"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,13 @@ struct HomeView: View {
|
|||||||
micStatus != .granted || speechStatus != .granted
|
micStatus != .granted || speechStatus != .granted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Can the user start a session right now from the Home footer? Only when
|
||||||
|
/// nothing is live/starting and permissions are already granted (otherwise
|
||||||
|
/// the permission guidance card is the correct call to action).
|
||||||
|
private var canManuallyStartSession: Bool {
|
||||||
|
!sessionIsLive && !needsPermissionSetup
|
||||||
|
}
|
||||||
|
|
||||||
private var shouldShowKeyboardHint: Bool {
|
private var shouldShowKeyboardHint: Bool {
|
||||||
!keyboardHintDismissed
|
!keyboardHintDismissed
|
||||||
&& !KeyboardSetupBridge.isReadyForOnboardingSkip
|
&& !KeyboardSetupBridge.isReadyForOnboardingSkip
|
||||||
@@ -106,9 +113,6 @@ struct HomeView: View {
|
|||||||
private func refreshPermissionStatuses() {
|
private func refreshPermissionStatuses() {
|
||||||
micStatus = AppPermissions.micStatus
|
micStatus = AppPermissions.micStatus
|
||||||
speechStatus = AppPermissions.speechStatus
|
speechStatus = AppPermissions.speechStatus
|
||||||
if AppPermissions.flowRequirementsMet {
|
|
||||||
flowManager.autoStartIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handlePermissionGuidanceAction() {
|
private func handlePermissionGuidanceAction() {
|
||||||
@@ -201,6 +205,18 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.leading, Spacing.xs)
|
.padding(.leading, Spacing.xs)
|
||||||
|
} else if canManuallyStartSession {
|
||||||
|
// Sessions auto-start on foreground, but after a manual stop /
|
||||||
|
// expiry the user needs a reliable, no-jump way back in.
|
||||||
|
Button {
|
||||||
|
flowManager.activateOnForeground()
|
||||||
|
} label: {
|
||||||
|
Text("home.flow.startShort")
|
||||||
|
.font(TypeStyle.caption2)
|
||||||
|
.foregroundStyle(palette.accent)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.leading, Spacing.xs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.fixedSize(horizontal: true, vertical: false)
|
.fixedSize(horizontal: true, vertical: false)
|
||||||
@@ -337,8 +353,7 @@ struct HomeView: View {
|
|||||||
EngineServiceLabel.summary(
|
EngineServiceLabel.summary(
|
||||||
engineMode: config.engineMode,
|
engineMode: config.engineMode,
|
||||||
providerId: config.providerId,
|
providerId: config.providerId,
|
||||||
model: config.model,
|
model: config.model
|
||||||
localASRBackend: config.localASRBackend
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.font(TypeStyle.caption2)
|
.font(TypeStyle.caption2)
|
||||||
|
|||||||
@@ -143,8 +143,7 @@ struct KeyboardPreviewSheet: View {
|
|||||||
EngineServiceLabel.summary(
|
EngineServiceLabel.summary(
|
||||||
engineMode: config.engineMode,
|
engineMode: config.engineMode,
|
||||||
providerId: config.providerId,
|
providerId: config.providerId,
|
||||||
model: config.model,
|
model: config.model
|
||||||
localASRBackend: config.localASRBackend
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// MainAppRoot.swift
|
||||||
|
// OSGKeyboard · Main App
|
||||||
|
//
|
||||||
|
// Host-app shell that owns `ProviderConfig` and `FlowSessionManager`.
|
||||||
|
// Only constructed when `AppGroup.isAvailable` so the error path never
|
||||||
|
// touches App Group–backed singletons.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import OSGKeyboardShared
|
||||||
|
|
||||||
|
struct MainAppRoot: View {
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
|
||||||
|
@StateObject private var config = ProviderConfig.shared
|
||||||
|
@StateObject private var flowManager = FlowSessionManager()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if config.hasCompletedOnboarding {
|
||||||
|
MainTabView()
|
||||||
|
} else {
|
||||||
|
OnboardingView(config: config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.environment(\.locale, config.uiLanguage.swiftUILocale)
|
||||||
|
.environmentObject(flowManager)
|
||||||
|
.overlay {
|
||||||
|
if let context = flowManager.coldStartContext {
|
||||||
|
FlowColdStartOverlay(
|
||||||
|
context: context,
|
||||||
|
onReturnToHost: { flowManager.returnToPendingHostFromColdStart() },
|
||||||
|
onDismiss: { flowManager.dismissColdStartOverlay() }
|
||||||
|
)
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: flowManager.coldStartContext != nil)
|
||||||
|
.onAppear {
|
||||||
|
flowManager.setAppForeground(scenePhase == .active)
|
||||||
|
flowManager.activateOnForeground()
|
||||||
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .osgKeyboardOpenURL)) { notification in
|
||||||
|
guard let url = notification.userInfo?["url"] as? URL else { return }
|
||||||
|
handleIncomingURL(url)
|
||||||
|
}
|
||||||
|
.onChange(of: config.hasCompletedOnboarding) { _, done in
|
||||||
|
if done {
|
||||||
|
flowManager.activateOnForeground()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: scenePhase) { _, phase in
|
||||||
|
flowManager.handleScenePhase(phase)
|
||||||
|
guard phase == .active, config.hasCompletedOnboarding else { return }
|
||||||
|
flowManager.activateOnForeground()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleIncomingURL(_ url: URL) {
|
||||||
|
guard url.scheme == "osgkeyboard" else { return }
|
||||||
|
switch url.host {
|
||||||
|
case "startflow":
|
||||||
|
flowManager.startSession(coldStart: true)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,5 @@ struct MainTabView: View {
|
|||||||
// Keep home card/input/tab layout fixed when system keyboard appears.
|
// Keep home card/input/tab layout fixed when system keyboard appears.
|
||||||
// Let the keyboard overlay the content instead of pushing it.
|
// Let the keyboard overlay the content instead of pushing it.
|
||||||
.ignoresSafeArea(.keyboard, edges: .bottom)
|
.ignoresSafeArea(.keyboard, edges: .bottom)
|
||||||
.onAppear { flowManager.autoStartIfNeeded() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,9 +107,7 @@ struct OnboardingView: View {
|
|||||||
private func applyOnboardingDefaultsIfNeeded() {
|
private func applyOnboardingDefaultsIfNeeded() {
|
||||||
guard !config.hasCompletedOnboarding, config.onboardingPage == 0 else { return }
|
guard !config.hasCompletedOnboarding, config.onboardingPage == 0 else { return }
|
||||||
// First-time users with no API key: default to local for a faster path.
|
// First-time users with no API key: default to local for a faster path.
|
||||||
// v0.2.0: no per-user ASR backend selection — iOS `SpeechAnalyzer`
|
// v0.2.0: iOS SpeechAnalyzer is the only on-device ASR path.
|
||||||
// is the only local option, so we don't need to mutate
|
|
||||||
// `config.localASRBackend` here.
|
|
||||||
if config.apiKey.isEmpty, config.engineMode == "cloud" {
|
if config.apiKey.isEmpty, config.engineMode == "cloud" {
|
||||||
config.engineMode = "local"
|
config.engineMode = "local"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ enum ProviderLogo {
|
|||||||
case "qwen": return "qwen"
|
case "qwen": return "qwen"
|
||||||
case "moonshot": return "moonshot"
|
case "moonshot": return "moonshot"
|
||||||
case "zhipu": return "zhipu"
|
case "zhipu": return "zhipu"
|
||||||
|
case "mimo": return "mimo"
|
||||||
case "custom": return "custom"
|
case "custom": return "custom"
|
||||||
default: return nil
|
default: return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ struct SettingsView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: Spacing.md) {
|
VStack(spacing: Spacing.md) {
|
||||||
languageAndPolishSection
|
languageAndPolishSection
|
||||||
|
flowSessionSection
|
||||||
engineSection
|
engineSection
|
||||||
// v0.2.1: hide provider/api card when the
|
// v0.2.1: hide provider/api card when the
|
||||||
// local engine is active regardless of the
|
// local engine is active regardless of the
|
||||||
@@ -101,6 +102,44 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Flow session
|
||||||
|
|
||||||
|
private var flowSessionSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
||||||
|
sectionHeader("settings.flow.title")
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Toggle(isOn: $config.flowSkipAppSwitch) {
|
||||||
|
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||||
|
Text("settings.flow.skipAppSwitch.title")
|
||||||
|
.font(TypeStyle.body)
|
||||||
|
.foregroundStyle(palette.textPrimary)
|
||||||
|
Text("settings.flow.skipAppSwitch.subtitle")
|
||||||
|
.font(TypeStyle.caption2)
|
||||||
|
.foregroundStyle(palette.textTertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(palette.accent)
|
||||||
|
.padding(.horizontal, Spacing.md)
|
||||||
|
.padding(.vertical, Spacing.sm)
|
||||||
|
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||||
|
|
||||||
|
Divider().background(palette.divider)
|
||||||
|
|
||||||
|
FlowInactivityPickerRow(
|
||||||
|
selection: Binding(
|
||||||
|
get: { config.flowInactivityDuration },
|
||||||
|
set: { config.flowInactivityDuration = $0 }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||||
|
.stroke(palette.divider, lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Engine
|
// MARK: - Engine
|
||||||
|
|
||||||
private var engineSection: some View {
|
private var engineSection: some View {
|
||||||
@@ -434,6 +473,31 @@ private struct AppLanguagePickerRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Flow inactivity picker row
|
||||||
|
|
||||||
|
private struct FlowInactivityPickerRow: View {
|
||||||
|
@Binding var selection: FlowInactivityDuration
|
||||||
|
|
||||||
|
private var options: [(id: String, label: String)] {
|
||||||
|
FlowInactivityDuration.allCases.map { duration in
|
||||||
|
(duration.rawValue, AppL10n.string(duration.labelKey))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
PickerRow(
|
||||||
|
title: AppL10n.string("settings.flow.inactivity.title"),
|
||||||
|
options: options,
|
||||||
|
selection: Binding(
|
||||||
|
get: { selection.rawValue },
|
||||||
|
set: { newValue in
|
||||||
|
selection = FlowInactivityDuration(rawValue: newValue) ?? .default
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Handedness picker row
|
// MARK: - Handedness picker row
|
||||||
|
|
||||||
private struct HandednessPickerRow: View {
|
private struct HandednessPickerRow: View {
|
||||||
|
|||||||
@@ -106,6 +106,7 @@
|
|||||||
"provider.qwen" = "Qwen (DashScope)";
|
"provider.qwen" = "Qwen (DashScope)";
|
||||||
"provider.zhipu" = "Zhipu GLM";
|
"provider.zhipu" = "Zhipu GLM";
|
||||||
"provider.moonshot" = "Moonshot";
|
"provider.moonshot" = "Moonshot";
|
||||||
|
"provider.mimo" = "Xiaomi MiMo";
|
||||||
"provider.custom" = "Custom";
|
"provider.custom" = "Custom";
|
||||||
"settings.api.title" = "API";
|
"settings.api.title" = "API";
|
||||||
"settings.language.title" = "Language";
|
"settings.language.title" = "Language";
|
||||||
@@ -295,6 +296,7 @@
|
|||||||
"home.flow.start" = "Start voice session";
|
"home.flow.start" = "Start voice session";
|
||||||
"home.flow.end" = "End voice session";
|
"home.flow.end" = "End voice session";
|
||||||
"home.flow.endShort" = "End";
|
"home.flow.endShort" = "End";
|
||||||
|
"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.dictationDuration" = "Dictation time";
|
||||||
@@ -359,3 +361,72 @@
|
|||||||
|
|
||||||
/* v0.3.0: Polish intensity */
|
/* v0.3.0: Polish intensity */
|
||||||
"settings.polishIntensity.title" = "Polish intensity";
|
"settings.polishIntensity.title" = "Polish intensity";
|
||||||
|
|
||||||
|
/* Flow session policy */
|
||||||
|
"settings.flow.title" = "Voice session";
|
||||||
|
"settings.flow.skipAppSwitch.title" = "Skip app switch";
|
||||||
|
"settings.flow.skipAppSwitch.subtitle" = "After a cold start, try to return to the app you came from.";
|
||||||
|
"settings.flow.inactivity.title" = "End session after inactivity";
|
||||||
|
"settings.flow.inactivity.10m" = "10 minutes";
|
||||||
|
"settings.flow.inactivity.30m" = "30 minutes";
|
||||||
|
"settings.flow.inactivity.3h" = "3 hours";
|
||||||
|
"settings.flow.inactivity.12h" = "12 hours";
|
||||||
|
"settings.flow.inactivity.24h" = "24 hours";
|
||||||
|
|
||||||
|
/* Cold-start handoff (scheme B) */
|
||||||
|
"flow.coldStart.title" = "Voice is ready";
|
||||||
|
"flow.coldStart.swipeHint" = "Swipe up from the bottom edge to return to your previous app.";
|
||||||
|
"flow.coldStart.swipeHint.withSystemBack" = "Tap ‹ in the top-left or swipe up from the bottom to return.";
|
||||||
|
"flow.coldStart.dismiss" = "Got it";
|
||||||
|
"flow.coldStart.swipeAccessibility" = "Swipe up from the bottom to return";
|
||||||
|
"flow.coldStart.alert.title" = "Voice is ready";
|
||||||
|
"flow.coldStart.alert.message" = "You can return to continue typing, or swipe up from the bottom.";
|
||||||
|
"flow.coldStart.return.named" = "Return to %@";
|
||||||
|
"flow.coldStart.return.generic" = "Return to app";
|
||||||
|
|
||||||
|
/* Host app display names (scheme C whitelist) */
|
||||||
|
"hostApp.wechat" = "WeChat";
|
||||||
|
"hostApp.qq" = "QQ";
|
||||||
|
"hostApp.wecom" = "WeCom";
|
||||||
|
"hostApp.dingtalk" = "DingTalk";
|
||||||
|
"hostApp.lark" = "Lark";
|
||||||
|
"hostApp.telegram" = "Telegram";
|
||||||
|
"hostApp.whatsapp" = "WhatsApp";
|
||||||
|
"hostApp.line" = "LINE";
|
||||||
|
"hostApp.messenger" = "Messenger";
|
||||||
|
"hostApp.slack" = "Slack";
|
||||||
|
"hostApp.teams" = "Microsoft Teams";
|
||||||
|
"hostApp.discord" = "Discord";
|
||||||
|
"hostApp.notion" = "Notion";
|
||||||
|
"hostApp.bear" = "Bear";
|
||||||
|
"hostApp.obsidian" = "Obsidian";
|
||||||
|
"hostApp.drafts" = "Drafts";
|
||||||
|
"hostApp.gmail" = "Gmail";
|
||||||
|
"hostApp.outlook" = "Outlook";
|
||||||
|
"hostApp.chrome" = "Chrome";
|
||||||
|
"hostApp.weibo" = "Weibo";
|
||||||
|
"hostApp.xiaohongshu" = "RED";
|
||||||
|
"hostApp.signal" = "Signal";
|
||||||
|
"hostApp.kakaotalk" = "KakaoTalk";
|
||||||
|
"hostApp.viber" = "Viber";
|
||||||
|
"hostApp.zalo" = "Zalo";
|
||||||
|
"hostApp.skype" = "Skype";
|
||||||
|
"hostApp.zoom" = "Zoom";
|
||||||
|
"hostApp.things" = "Things";
|
||||||
|
"hostApp.todoist" = "Todoist";
|
||||||
|
"hostApp.evernote" = "Evernote";
|
||||||
|
"hostApp.onenote" = "OneNote";
|
||||||
|
"hostApp.spark" = "Spark";
|
||||||
|
"hostApp.firefox" = "Firefox";
|
||||||
|
"hostApp.edge" = "Edge";
|
||||||
|
"hostApp.facebook" = "Facebook";
|
||||||
|
"hostApp.instagram" = "Instagram";
|
||||||
|
"hostApp.x" = "X";
|
||||||
|
"hostApp.threads" = "Threads";
|
||||||
|
"hostApp.snapchat" = "Snapchat";
|
||||||
|
"hostApp.reddit" = "Reddit";
|
||||||
|
"hostApp.pinterest" = "Pinterest";
|
||||||
|
"hostApp.zhihu" = "Zhihu";
|
||||||
|
"hostApp.bilibili" = "Bilibili";
|
||||||
|
"hostApp.douyin" = "Douyin";
|
||||||
|
"hostApp.tiktok" = "TikTok";
|
||||||
|
|||||||
@@ -106,6 +106,7 @@
|
|||||||
"provider.qwen" = "通义千问";
|
"provider.qwen" = "通义千问";
|
||||||
"provider.zhipu" = "智谱 GLM";
|
"provider.zhipu" = "智谱 GLM";
|
||||||
"provider.moonshot" = "月之暗面";
|
"provider.moonshot" = "月之暗面";
|
||||||
|
"provider.mimo" = "小米 MiMo";
|
||||||
"provider.custom" = "自定义";
|
"provider.custom" = "自定义";
|
||||||
"settings.api.title" = "接口";
|
"settings.api.title" = "接口";
|
||||||
"settings.language.title" = "语言";
|
"settings.language.title" = "语言";
|
||||||
@@ -294,6 +295,7 @@
|
|||||||
"home.flow.start" = "启动语音会话";
|
"home.flow.start" = "启动语音会话";
|
||||||
"home.flow.end" = "结束语音会话";
|
"home.flow.end" = "结束语音会话";
|
||||||
"home.flow.endShort" = "结束";
|
"home.flow.endShort" = "结束";
|
||||||
|
"home.flow.startShort" = "开启";
|
||||||
"home.preview.label" = "输入测试";
|
"home.preview.label" = "输入测试";
|
||||||
"home.preview.placeholder" = "点这里试试键盘";
|
"home.preview.placeholder" = "点这里试试键盘";
|
||||||
"home.stats.dictationDuration" = "听写时长";
|
"home.stats.dictationDuration" = "听写时长";
|
||||||
@@ -358,3 +360,72 @@
|
|||||||
|
|
||||||
/* v0.3.0: 润色档位 */
|
/* v0.3.0: 润色档位 */
|
||||||
"settings.polishIntensity.title" = "润色档位";
|
"settings.polishIntensity.title" = "润色档位";
|
||||||
|
|
||||||
|
/* Flow 会话策略 */
|
||||||
|
"settings.flow.title" = "语音会话";
|
||||||
|
"settings.flow.skipAppSwitch.title" = "跳过应用切换";
|
||||||
|
"settings.flow.skipAppSwitch.subtitle" = "冷启动完成后,尝试自动返回来源 App。";
|
||||||
|
"settings.flow.inactivity.title" = "无活动后结束会话";
|
||||||
|
"settings.flow.inactivity.10m" = "10 分钟";
|
||||||
|
"settings.flow.inactivity.30m" = "30 分钟";
|
||||||
|
"settings.flow.inactivity.3h" = "3 小时";
|
||||||
|
"settings.flow.inactivity.12h" = "12 小时";
|
||||||
|
"settings.flow.inactivity.24h" = "24 小时";
|
||||||
|
|
||||||
|
/* 冷启动兜底(方案 B) */
|
||||||
|
"flow.coldStart.title" = "语音已就绪";
|
||||||
|
"flow.coldStart.swipeHint" = "从屏幕底部边缘向上滑动,返回上一个 App。";
|
||||||
|
"flow.coldStart.swipeHint.withSystemBack" = "点左上角 ‹ 或从底部向上滑动返回。";
|
||||||
|
"flow.coldStart.dismiss" = "知道了";
|
||||||
|
"flow.coldStart.swipeAccessibility" = "从底部向上滑动返回";
|
||||||
|
"flow.coldStart.alert.title" = "语音已就绪";
|
||||||
|
"flow.coldStart.alert.message" = "可返回继续输入,或从底部向上滑动返回。";
|
||||||
|
"flow.coldStart.return.named" = "返回%@";
|
||||||
|
"flow.coldStart.return.generic" = "返回 App";
|
||||||
|
|
||||||
|
/* 宿主 App 显示名(方案 C 白名单) */
|
||||||
|
"hostApp.wechat" = "微信";
|
||||||
|
"hostApp.qq" = "QQ";
|
||||||
|
"hostApp.wecom" = "企业微信";
|
||||||
|
"hostApp.dingtalk" = "钉钉";
|
||||||
|
"hostApp.lark" = "飞书";
|
||||||
|
"hostApp.telegram" = "Telegram";
|
||||||
|
"hostApp.whatsapp" = "WhatsApp";
|
||||||
|
"hostApp.line" = "LINE";
|
||||||
|
"hostApp.messenger" = "Messenger";
|
||||||
|
"hostApp.slack" = "Slack";
|
||||||
|
"hostApp.teams" = "Microsoft Teams";
|
||||||
|
"hostApp.discord" = "Discord";
|
||||||
|
"hostApp.notion" = "Notion";
|
||||||
|
"hostApp.bear" = "Bear";
|
||||||
|
"hostApp.obsidian" = "Obsidian";
|
||||||
|
"hostApp.drafts" = "Drafts";
|
||||||
|
"hostApp.gmail" = "Gmail";
|
||||||
|
"hostApp.outlook" = "Outlook";
|
||||||
|
"hostApp.chrome" = "Chrome";
|
||||||
|
"hostApp.weibo" = "微博";
|
||||||
|
"hostApp.xiaohongshu" = "小红书";
|
||||||
|
"hostApp.signal" = "Signal";
|
||||||
|
"hostApp.kakaotalk" = "KakaoTalk";
|
||||||
|
"hostApp.viber" = "Viber";
|
||||||
|
"hostApp.zalo" = "Zalo";
|
||||||
|
"hostApp.skype" = "Skype";
|
||||||
|
"hostApp.zoom" = "Zoom";
|
||||||
|
"hostApp.things" = "Things";
|
||||||
|
"hostApp.todoist" = "Todoist";
|
||||||
|
"hostApp.evernote" = "印象笔记";
|
||||||
|
"hostApp.onenote" = "OneNote";
|
||||||
|
"hostApp.spark" = "Spark";
|
||||||
|
"hostApp.firefox" = "Firefox";
|
||||||
|
"hostApp.edge" = "Edge";
|
||||||
|
"hostApp.facebook" = "Facebook";
|
||||||
|
"hostApp.instagram" = "Instagram";
|
||||||
|
"hostApp.x" = "X";
|
||||||
|
"hostApp.threads" = "Threads";
|
||||||
|
"hostApp.snapchat" = "Snapchat";
|
||||||
|
"hostApp.reddit" = "Reddit";
|
||||||
|
"hostApp.pinterest" = "Pinterest";
|
||||||
|
"hostApp.zhihu" = "知乎";
|
||||||
|
"hostApp.bilibili" = "哔哩哔哩";
|
||||||
|
"hostApp.douyin" = "抖音";
|
||||||
|
"hostApp.tiktok" = "TikTok";
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
<key>PrefersRightToLeft</key>
|
<key>PrefersRightToLeft</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>PrimaryLanguage</key>
|
<key>PrimaryLanguage</key>
|
||||||
<string>en-US</string>
|
<string>mis</string>
|
||||||
<key>RequestsOpenAccess</key>
|
<key>RequestsOpenAccess</key>
|
||||||
<true/>
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,6 @@ public struct AppGroupPersistor {
|
|||||||
// Both engines always polish; ignore legacy off/transcribe modeId.
|
// Both engines always polish; ignore legacy off/transcribe modeId.
|
||||||
state.mode = .polish
|
state.mode = .polish
|
||||||
state.engineMode = store.engineMode
|
state.engineMode = store.engineMode
|
||||||
state.localASRBackend = store.localASRBackend
|
|
||||||
// v0.2.1 follow-up: only the target locale is persisted —
|
// v0.2.1 follow-up: only the target locale is persisted —
|
||||||
// `translationEnabled` is derived from it. Hydrate once at
|
// `translationEnabled` is derived from it. Hydrate once at
|
||||||
// startup; `refreshRuntimeFlags` keeps the chip in sync while
|
// startup; `refreshRuntimeFlags` keeps the chip in sync while
|
||||||
@@ -42,16 +41,10 @@ public struct AppGroupPersistor {
|
|||||||
state.translationTargetLocaleId = store.translationTargetLocaleId
|
state.translationTargetLocaleId = store.translationTargetLocaleId
|
||||||
state.handednessPreference = store.handednessPreference
|
state.handednessPreference = store.handednessPreference
|
||||||
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
|
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
|
||||||
state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled
|
|
||||||
state.micDisabled = store.isCloudAPIKeyMissingForVoiceInput
|
state.micDisabled = store.isCloudAPIKeyMissingForVoiceInput
|
||||||
state.micDisabledHint = store.isCloudAPIKeyMissingForVoiceInput
|
state.micDisabledHint = store.isCloudAPIKeyMissingForVoiceInput
|
||||||
? ExtL10n.string("keyboard.mic.disabled.missingApiKey")
|
? ExtL10n.string("keyboard.mic.disabled.missingApiKey")
|
||||||
: ""
|
: ""
|
||||||
// v0.2.0: iOS `SpeechAnalyzer` is always ready; mirror that
|
|
||||||
// into the State flags so downstream consumers see the same
|
|
||||||
// shape they did when the previous Qwen3 stack reported "ready".
|
|
||||||
state.localModelsReady = true
|
|
||||||
state.localModelsLoaded = false
|
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
// Print a masked view of the live App Group config so we can see
|
// Print a masked view of the live App Group config so we can see
|
||||||
@@ -74,7 +67,6 @@ public struct AppGroupPersistor {
|
|||||||
model = \(store.model)
|
model = \(store.model)
|
||||||
modeId = \(store.modeId)
|
modeId = \(store.modeId)
|
||||||
localeId = \(store.localeId)
|
localeId = \(store.localeId)
|
||||||
localASRBackend = \(store.localASRBackend.rawValue)
|
|
||||||
""")
|
""")
|
||||||
#endif
|
#endif
|
||||||
return .loaded
|
return .loaded
|
||||||
@@ -93,8 +85,6 @@ public struct AppGroupPersistor {
|
|||||||
guard AppGroup.isAvailable else { return }
|
guard AppGroup.isAvailable else { return }
|
||||||
let store = AppGroupStore()
|
let store = AppGroupStore()
|
||||||
state.engineMode = store.engineMode
|
state.engineMode = store.engineMode
|
||||||
state.localASRBackend = store.localASRBackend
|
|
||||||
state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled
|
|
||||||
let shouldProtectTranslation = protectTranslationUntil.map { Date() < $0 } ?? false
|
let shouldProtectTranslation = protectTranslationUntil.map { Date() < $0 } ?? false
|
||||||
if !shouldProtectTranslation {
|
if !shouldProtectTranslation {
|
||||||
state.translationTargetLocaleId = store.translationTargetLocaleId
|
state.translationTargetLocaleId = store.translationTargetLocaleId
|
||||||
@@ -105,11 +95,6 @@ public struct AppGroupPersistor {
|
|||||||
state.micDisabledHint = store.isCloudAPIKeyMissingForVoiceInput
|
state.micDisabledHint = store.isCloudAPIKeyMissingForVoiceInput
|
||||||
? ExtL10n.string("keyboard.mic.disabled.missingApiKey")
|
? ExtL10n.string("keyboard.mic.disabled.missingApiKey")
|
||||||
: ""
|
: ""
|
||||||
// v0.2.0: iOS `SpeechAnalyzer` is always ready. Keep these
|
|
||||||
// toggles here so the keyboard UI doesn't flicker if the host
|
|
||||||
// app briefly clears them while refactoring.
|
|
||||||
state.localModelsReady = true
|
|
||||||
state.localModelsLoaded = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist `mode` to the App Group store.
|
/// Persist `mode` to the App Group store.
|
||||||
@@ -130,12 +115,6 @@ public struct AppGroupPersistor {
|
|||||||
AppGroupStore().setEngineMode(engineMode)
|
AppGroupStore().setEngineMode(engineMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist `localASRBackend` to the App Group store.
|
|
||||||
public func persist(localASRBackend: LocalASRBackend) {
|
|
||||||
guard AppGroup.isAvailable else { return }
|
|
||||||
AppGroupStore().setLocalASRBackend(localASRBackend)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// v0.2.1: persist translation target locale id (e.g. `"en"`,
|
/// v0.2.1: persist translation target locale id (e.g. `"en"`,
|
||||||
/// `"ja"`, or `TranslationLanguageCatalog.offLocaleId`). The
|
/// `"ja"`, or `TranslationLanguageCatalog.offLocaleId`). The
|
||||||
/// chip / picker call this through `KeyboardState.setTranslationTargetLocaleId`.
|
/// chip / picker call this through `KeyboardState.setTranslationTargetLocaleId`.
|
||||||
@@ -149,4 +128,4 @@ public struct AppGroupPersistor {
|
|||||||
guard AppGroup.isAvailable else { return }
|
guard AppGroup.isAvailable else { return }
|
||||||
AppGroupStore().setTranslationTargetLocaleId(translationTargetLocaleId)
|
AppGroupStore().setTranslationTargetLocaleId(translationTargetLocaleId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// CursorDragController.swift
|
||||||
|
// OSGKeyboard · Keyboard Extension
|
||||||
|
//
|
||||||
|
// Cursor-drag hint chrome and batched caret moves via textDocumentProxy.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import OSGKeyboardShared
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class CursorDragController {
|
||||||
|
private let state: KeyboardState
|
||||||
|
private let adjustTextPosition: (Int) -> Void
|
||||||
|
private weak var parentView: UIView?
|
||||||
|
|
||||||
|
private var cursorDragHintLabel: UILabel?
|
||||||
|
private var pendingHorizontalCursorSteps = 0
|
||||||
|
private var pendingVerticalCursorSteps = 0
|
||||||
|
private var cursorMoveFlushScheduled = false
|
||||||
|
private let cursorLineHaptic = UIImpactFeedbackGenerator(style: .light)
|
||||||
|
|
||||||
|
private static let cursorVerticalChunkSize = 20
|
||||||
|
|
||||||
|
init(
|
||||||
|
state: KeyboardState,
|
||||||
|
adjustTextPosition: @escaping (Int) -> Void
|
||||||
|
) {
|
||||||
|
self.state = state
|
||||||
|
self.adjustTextPosition = adjustTextPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
func install(on view: UIView) {
|
||||||
|
parentView = view
|
||||||
|
let hint = UILabel()
|
||||||
|
hint.text = ExtL10n.string("keyboard.cursorDrag.centerHint")
|
||||||
|
hint.font = .systemFont(ofSize: 22, weight: .medium)
|
||||||
|
hint.textColor = UIColor.label.withAlphaComponent(0.10)
|
||||||
|
hint.textAlignment = .center
|
||||||
|
hint.numberOfLines = 1
|
||||||
|
hint.adjustsFontSizeToFitWidth = true
|
||||||
|
hint.minimumScaleFactor = 0.7
|
||||||
|
hint.isUserInteractionEnabled = false
|
||||||
|
hint.isHidden = true
|
||||||
|
hint.alpha = 0
|
||||||
|
view.addSubview(hint)
|
||||||
|
cursorDragHintLabel = hint
|
||||||
|
layoutChrome()
|
||||||
|
}
|
||||||
|
|
||||||
|
func layoutChrome() {
|
||||||
|
guard let view = parentView else { return }
|
||||||
|
cursorDragHintLabel?.frame = view.bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCursorDragActive(_ active: Bool) {
|
||||||
|
state.cursorDragActive = active
|
||||||
|
updateCursorDragWash(active: active)
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveCursorHorizontally(by steps: Int) {
|
||||||
|
guard steps != 0 else { return }
|
||||||
|
pendingHorizontalCursorSteps += steps
|
||||||
|
scheduleCursorMoveFlush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveCursorVertically(by steps: Int) {
|
||||||
|
guard steps != 0 else { return }
|
||||||
|
pendingVerticalCursorSteps += steps
|
||||||
|
scheduleCursorMoveFlush()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateCursorDragWash(active: Bool) {
|
||||||
|
if active {
|
||||||
|
cursorLineHaptic.prepare()
|
||||||
|
}
|
||||||
|
layoutChrome()
|
||||||
|
guard let hint = cursorDragHintLabel else { return }
|
||||||
|
if active {
|
||||||
|
hint.isHidden = false
|
||||||
|
UIView.animate(withDuration: 0.12) { hint.alpha = 1 }
|
||||||
|
} else {
|
||||||
|
UIView.animate(withDuration: 0.12, animations: { hint.alpha = 0 }) { [weak self] _ in
|
||||||
|
guard let self, !self.state.cursorDragActive else { return }
|
||||||
|
hint.isHidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleCursorMoveFlush() {
|
||||||
|
guard !cursorMoveFlushScheduled else { return }
|
||||||
|
cursorMoveFlushScheduled = true
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.012) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.cursorMoveFlushScheduled = false
|
||||||
|
|
||||||
|
let horizontal = self.pendingHorizontalCursorSteps
|
||||||
|
let vertical = self.pendingVerticalCursorSteps
|
||||||
|
self.pendingHorizontalCursorSteps = 0
|
||||||
|
self.pendingVerticalCursorSteps = 0
|
||||||
|
|
||||||
|
if horizontal != 0 {
|
||||||
|
OSGLog.keyboardExt.info("adjustTextPosition h=\(horizontal)")
|
||||||
|
self.adjustTextPosition(horizontal)
|
||||||
|
}
|
||||||
|
|
||||||
|
if vertical != 0 {
|
||||||
|
self.applyVerticalCursorSteps(vertical)
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.pendingHorizontalCursorSteps != 0 || self.pendingVerticalCursorSteps != 0 {
|
||||||
|
self.scheduleCursorMoveFlush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyVerticalCursorSteps(_ steps: Int) {
|
||||||
|
let direction = steps > 0 ? 1 : -1
|
||||||
|
var remaining = abs(steps)
|
||||||
|
let chunk = Self.cursorVerticalChunkSize
|
||||||
|
|
||||||
|
while remaining > 0 {
|
||||||
|
adjustTextPosition(direction * chunk)
|
||||||
|
cursorLineHaptic.impactOccurred()
|
||||||
|
cursorLineHaptic.prepare()
|
||||||
|
remaining -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,18 @@
|
|||||||
// HostAppLauncher.swift
|
// HostAppLauncher.swift
|
||||||
// OSGKeyboard · Keyboard Extension
|
// OSGKeyboard · Keyboard Extension
|
||||||
//
|
//
|
||||||
// Opens the host app via URL using extension-safe strategies:
|
// Opens the host app from the keyboard extension.
|
||||||
// 1. `extensionContext.open` (official)
|
//
|
||||||
// 2. Responder-chain `UIApplication.open` (TypeWhisper pattern)
|
// Reality check (verified against iOS 18–26 behaviour):
|
||||||
|
// • `extensionContext.open` is documented for Today widgets only; for a
|
||||||
|
// keyboard extension it resolves `false`, so we do not use it.
|
||||||
|
// • The deprecated `openURL:` selector hack was disabled in iOS 18
|
||||||
|
// ("BUG IN CLIENT OF UIKIT … migrate to open(_:options:completionHandler:)").
|
||||||
|
// • The still-working path is: walk the responder chain to `UIApplication`
|
||||||
|
// and call the non-deprecated `open(_:options:completionHandler:)`. This
|
||||||
|
// requires Full Access and grows less reliable on newer iOS, so we report
|
||||||
|
// the *real* success from the completion handler instead of assuming it
|
||||||
|
// worked — callers degrade to on-keyboard guidance when it returns false.
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@@ -14,34 +23,17 @@ enum HostAppLauncher {
|
|||||||
from controller: KeyboardViewController,
|
from controller: KeyboardViewController,
|
||||||
completion: @escaping @MainActor (Bool) -> Void
|
completion: @escaping @MainActor (Bool) -> Void
|
||||||
) {
|
) {
|
||||||
if let context = controller.extensionContext {
|
|
||||||
context.open(url) { success in
|
|
||||||
Task { @MainActor in
|
|
||||||
if success {
|
|
||||||
completion(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(openViaResponderChain(url, from: controller))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
completion(openViaResponderChain(url, from: controller))
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private static func openViaResponderChain(
|
|
||||||
_ url: URL,
|
|
||||||
from controller: KeyboardViewController
|
|
||||||
) -> Bool {
|
|
||||||
var responder: UIResponder? = controller
|
var responder: UIResponder? = controller
|
||||||
while let current = responder {
|
while let current = responder {
|
||||||
if let application = current as? UIApplication {
|
if let application = current as? UIApplication {
|
||||||
application.open(url, options: [:]) { _ in }
|
application.open(url, options: [:]) { success in
|
||||||
return true
|
Task { @MainActor in completion(success) }
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
responder = current.next
|
responder = current.next
|
||||||
}
|
}
|
||||||
return false
|
// No `UIApplication` in the responder chain — cannot open the host app.
|
||||||
|
completion(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// KeyboardConfigSync.swift
|
||||||
|
// OSGKeyboard · Keyboard Extension
|
||||||
|
//
|
||||||
|
// App Group config hydration, Darwin observers, and onboarding mirroring.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import OSGKeyboardShared
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class KeyboardConfigSync {
|
||||||
|
private let state: KeyboardState
|
||||||
|
private let persistor: AppGroupPersistor
|
||||||
|
private let onFlowSessionChanged: () -> Void
|
||||||
|
|
||||||
|
/// Grace period after a chip-side translation write during which the
|
||||||
|
/// 1 Hz App Group poll must not overwrite `translationTargetLocaleId`.
|
||||||
|
var translationConfigProtectedUntil: Date?
|
||||||
|
|
||||||
|
private var flowSessionDarwinObserver: FlowSessionDarwinObserver?
|
||||||
|
private var transcriptionDarwinObserver: FlowSessionDarwinObserver?
|
||||||
|
private var configDarwinObserver: FlowSessionDarwinObserver?
|
||||||
|
|
||||||
|
init(
|
||||||
|
state: KeyboardState,
|
||||||
|
persistor: AppGroupPersistor,
|
||||||
|
onFlowSessionChanged: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.state = state
|
||||||
|
self.persistor = persistor
|
||||||
|
self.onFlowSessionChanged = onFlowSessionChanged
|
||||||
|
}
|
||||||
|
|
||||||
|
func installDarwinObservers() {
|
||||||
|
flowSessionDarwinObserver = FlowSessionDarwinObserver { [weak self] in
|
||||||
|
self?.onFlowSessionChanged()
|
||||||
|
}
|
||||||
|
transcriptionDarwinObserver = FlowSessionDarwinObserver(
|
||||||
|
notificationName: FlowSessionDarwin.transcriptionNotificationName
|
||||||
|
) { [weak self] in
|
||||||
|
self?.onFlowSessionChanged()
|
||||||
|
}
|
||||||
|
configDarwinObserver = FlowSessionDarwinObserver(
|
||||||
|
notificationName: AppGroupConfigDarwin.notificationName
|
||||||
|
) { [weak self] in
|
||||||
|
self?.refreshConfigFromAppGroup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPersistedConfig() -> AppGroupLoadResult {
|
||||||
|
switch persistor.load(into: state) {
|
||||||
|
case .loaded:
|
||||||
|
OSGLog.keyboardExt.info(
|
||||||
|
"config loaded — cursorDragNavigationEnabled=\(self.state.cursorDragNavigationEnabled)"
|
||||||
|
)
|
||||||
|
syncOnboardingStateFromAppGroup()
|
||||||
|
return .loaded
|
||||||
|
case .unavailable:
|
||||||
|
state.phase = .error(
|
||||||
|
.appGroupUnavailable,
|
||||||
|
message: ExtL10n.string("keyboard.error.appGroupUnavailable")
|
||||||
|
)
|
||||||
|
return .unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshConfigFromAppGroup() {
|
||||||
|
persistor.refreshRuntimeFlags(
|
||||||
|
into: state,
|
||||||
|
protectTranslationUntil: translationConfigProtectedUntil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncOnboardingStateFromAppGroup() {
|
||||||
|
let store = AppGroupStore()
|
||||||
|
state.hasCompletedOnboarding = store.hasCompletedOnboarding
|
||||||
|
state.onboardingPage = store.onboardingPage
|
||||||
|
}
|
||||||
|
|
||||||
|
func autoAdvancePastKeyboardSetupStepIfNeeded() {
|
||||||
|
guard !state.hasCompletedOnboarding else { return }
|
||||||
|
guard state.onboardingPage == 3 else { return }
|
||||||
|
guard KeyboardSetupBridge.isReadyForOnboardingSkip else { return }
|
||||||
|
let store = AppGroupStore()
|
||||||
|
store.setOnboardingPage(4)
|
||||||
|
state.onboardingPage = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
func advanceOnboarding() {
|
||||||
|
let store = AppGroupStore()
|
||||||
|
let nextPage = min(4, store.onboardingPage + 1)
|
||||||
|
store.setOnboardingPage(nextPage)
|
||||||
|
state.onboardingPage = nextPage
|
||||||
|
}
|
||||||
|
|
||||||
|
func completeOnboarding() {
|
||||||
|
let store = AppGroupStore()
|
||||||
|
store.setHasCompletedOnboarding(true)
|
||||||
|
store.setOnboardingPage(4)
|
||||||
|
state.hasCompletedOnboarding = true
|
||||||
|
state.onboardingPage = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
func persistLocale(_ id: String) {
|
||||||
|
state.localeId = id
|
||||||
|
persistor.persist(localeId: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func persistEngineMode(_ mode: String) {
|
||||||
|
state.engineMode = mode
|
||||||
|
persistor.persist(engineMode: mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func persistTranslationTargetLocaleId(_ id: String) {
|
||||||
|
let resolved = TranslationLanguageCatalog.resolve(id).id
|
||||||
|
state.translationTargetLocaleId = resolved
|
||||||
|
translationConfigProtectedUntil = Date().addingTimeInterval(2.5)
|
||||||
|
persistor.persist(translationTargetLocaleId: resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
func persistMode(_ mode: KeyboardState.InputMode) {
|
||||||
|
state.mode = mode
|
||||||
|
persistor.persist(mode: mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
// KeyboardFlowCoordinator.swift
|
||||||
|
// OSGKeyboard · Keyboard Extension
|
||||||
|
//
|
||||||
|
// Flow session start, recording, watchdogs, and result delivery handling.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import OSGKeyboardShared
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class KeyboardFlowCoordinator {
|
||||||
|
private enum FlowWatchdog {
|
||||||
|
static let pollIntervalNs: UInt64 = 200_000_000
|
||||||
|
/// Give the user time to manually open the host app when auto-jump fails.
|
||||||
|
static let startTimeout: TimeInterval = 30
|
||||||
|
|
||||||
|
static func resultTimeout(engineMode: String) -> TimeInterval {
|
||||||
|
FlowSessionKeys.keyboardResultTimeout(engineMode: engineMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private let state: KeyboardState
|
||||||
|
private let textInserter: KeyboardTextInserter
|
||||||
|
private let hasFullAccess: () -> Bool
|
||||||
|
private let wakeLockView: () -> UIView?
|
||||||
|
private let openHostApp: (String) -> Void
|
||||||
|
private let detectAndStoreAppContext: () -> Void
|
||||||
|
private let scheduleAutoClearError: () -> Void
|
||||||
|
private let refreshConfigFromAppGroup: () -> Void
|
||||||
|
|
||||||
|
private var isPendingFlowStart = false
|
||||||
|
private var flowStartDeadline: TimeInterval = 0
|
||||||
|
private var isFlowRecording = false
|
||||||
|
private var flowWatchdogTask: Task<Void, Never>?
|
||||||
|
private var utteranceTimerTask: Task<Void, Never>?
|
||||||
|
private var utteranceStartedAt: TimeInterval = 0
|
||||||
|
private var wasFlowSessionActive = false
|
||||||
|
private var flowSessionMonitorTask: Task<Void, Never>?
|
||||||
|
private var isAwaitingFlowResult = false
|
||||||
|
|
||||||
|
init(
|
||||||
|
state: KeyboardState,
|
||||||
|
textInserter: KeyboardTextInserter,
|
||||||
|
hasFullAccess: @escaping () -> Bool,
|
||||||
|
wakeLockView: @escaping () -> UIView?,
|
||||||
|
openHostApp: @escaping (String) -> Void,
|
||||||
|
detectAndStoreAppContext: @escaping () -> Void,
|
||||||
|
scheduleAutoClearError: @escaping () -> Void,
|
||||||
|
refreshConfigFromAppGroup: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.state = state
|
||||||
|
self.textInserter = textInserter
|
||||||
|
self.hasFullAccess = hasFullAccess
|
||||||
|
self.wakeLockView = wakeLockView
|
||||||
|
self.openHostApp = openHostApp
|
||||||
|
self.detectAndStoreAppContext = detectAndStoreAppContext
|
||||||
|
self.scheduleAutoClearError = scheduleAutoClearError
|
||||||
|
self.refreshConfigFromAppGroup = refreshConfigFromAppGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
var preservesLifecycleOnDisappear: Bool {
|
||||||
|
isPendingFlowStart || isFlowRecording || isAwaitingFlowResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session/transcription changes are pushed in real time by Darwin
|
||||||
|
/// notifications (see `KeyboardConfigSync.installDarwinObservers`), so this
|
||||||
|
/// loop is only a low-frequency safety net for coalesced/dropped Darwin
|
||||||
|
/// signals — hence 3 s rather than 1 Hz to save battery while idle.
|
||||||
|
private static let sessionMonitorIntervalNs: UInt64 = 3_000_000_000
|
||||||
|
|
||||||
|
func startSessionMonitor() {
|
||||||
|
flowSessionMonitorTask?.cancel()
|
||||||
|
flowSessionMonitorTask = Task { @MainActor [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
self?.refreshSessionState()
|
||||||
|
try? await Task.sleep(nanoseconds: Self.sessionMonitorIntervalNs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopSessionMonitor() {
|
||||||
|
flowSessionMonitorTask?.cancel()
|
||||||
|
flowSessionMonitorTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshSessionState() {
|
||||||
|
refreshConfigFromAppGroup()
|
||||||
|
refreshFlowPartialIfNeeded()
|
||||||
|
consumePendingFlowDeliveryIfNeeded()
|
||||||
|
|
||||||
|
let active = FlowSessionBridge.isSessionActive()
|
||||||
|
state.flowSessionActive = active
|
||||||
|
|
||||||
|
if wasFlowSessionActive && !active && !isFlowRecording && !isPendingFlowStart {
|
||||||
|
switch state.phase {
|
||||||
|
case .recording, .processing:
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
showFlowSessionExpiredHint()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wasFlowSessionActive = active
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleRecording() {
|
||||||
|
switch state.phase {
|
||||||
|
case .recording:
|
||||||
|
pressEnded()
|
||||||
|
case .idle, .denied, .error:
|
||||||
|
pressBegan()
|
||||||
|
case .requestingPermissions, .processing:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pressBegan() {
|
||||||
|
switch state.phase {
|
||||||
|
case .idle, .denied, .error:
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !state.micDisabled else { return }
|
||||||
|
guard hasFullAccess() else {
|
||||||
|
let msg = ExtL10n.string("keyboard.error.fullAccessRequired")
|
||||||
|
state.phase = .error(.fullAccessRequired, message: msg)
|
||||||
|
scheduleAutoClearError()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard AppGroup.isAvailable else {
|
||||||
|
let msg = ExtL10n.string("keyboard.error.appGroupCommunication")
|
||||||
|
state.phase = .error(.appGroupUnavailable, message: msg)
|
||||||
|
scheduleAutoClearError()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
detectAndStoreAppContext()
|
||||||
|
|
||||||
|
if FlowSessionBridge.isSessionActive() {
|
||||||
|
startFlowRecording()
|
||||||
|
} else {
|
||||||
|
beginFlowStart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pressEnded() {
|
||||||
|
if isPendingFlowStart {
|
||||||
|
cancelPendingFlowStart()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard isFlowRecording else { return }
|
||||||
|
|
||||||
|
isFlowRecording = false
|
||||||
|
stopUtteranceCountdown()
|
||||||
|
ExtensionScreenWakeLock.release()
|
||||||
|
FlowSessionBridge.setRecordingState(.stopped)
|
||||||
|
state.phase = .processing
|
||||||
|
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
|
||||||
|
startFlowResultWatchdog()
|
||||||
|
}
|
||||||
|
|
||||||
|
func beginFlowStart() {
|
||||||
|
guard !isPendingFlowStart else { return }
|
||||||
|
isPendingFlowStart = true
|
||||||
|
isFlowRecording = false
|
||||||
|
flowStartDeadline = Date().timeIntervalSince1970 + FlowWatchdog.startTimeout
|
||||||
|
state.lastTranscript = ExtL10n.string("keyboard.flow.startingSession")
|
||||||
|
state.phase = .processing
|
||||||
|
openHostApp("startflow")
|
||||||
|
startFlowStartWatchdog()
|
||||||
|
debug("beginFlowStart")
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleHostAppOpenResult(path: String, success: Bool) {
|
||||||
|
debug("openHostApp path=\(path) success=\(success)")
|
||||||
|
guard !success else { return }
|
||||||
|
|
||||||
|
// The open genuinely failed (iOS blocked it / no Full Access). Don't
|
||||||
|
// let the 30s watchdog spin — cancel the pending start immediately and
|
||||||
|
// guide the user to open OSGKeyboard manually.
|
||||||
|
if path == "startflow", isPendingFlowStart {
|
||||||
|
isPendingFlowStart = false
|
||||||
|
flowStartDeadline = 0
|
||||||
|
stopFlowWatchdog()
|
||||||
|
showManualOpenHint(path: "startflow")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showManualOpenHint(path: path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelPipelineUnlessAwaitingResult() {
|
||||||
|
guard !isAwaitingFlowResult else { return }
|
||||||
|
if isFlowRecording || isPendingFlowStart {
|
||||||
|
if isFlowRecording {
|
||||||
|
FlowSessionBridge.setRecordingState(.aborted)
|
||||||
|
ExtensionScreenWakeLock.release()
|
||||||
|
}
|
||||||
|
isFlowRecording = false
|
||||||
|
isPendingFlowStart = false
|
||||||
|
stopUtteranceCountdown()
|
||||||
|
stopFlowWatchdog()
|
||||||
|
state.level = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private func consumePendingFlowDeliveryIfNeeded() {
|
||||||
|
if isAwaitingFlowResult {
|
||||||
|
if let delivery = FlowSessionBridge.consumeTranscriptionDelivery() {
|
||||||
|
isAwaitingFlowResult = false
|
||||||
|
stopFlowWatchdog()
|
||||||
|
textInserter.handleFlowTranscript(delivery)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let error = FlowSessionBridge.consumeTranscriptionError() {
|
||||||
|
isAwaitingFlowResult = false
|
||||||
|
stopFlowWatchdog()
|
||||||
|
state.phase = .error(
|
||||||
|
.fromFlowTranscription(error),
|
||||||
|
message: error.message
|
||||||
|
)
|
||||||
|
scheduleAutoClearError()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isPendingFlowStart, FlowSessionBridge.isSessionActive() {
|
||||||
|
completeFlowStartHandoff()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showFlowSessionExpiredHint() {
|
||||||
|
let message = ExtL10n.string("keyboard.flow.sessionExpired")
|
||||||
|
state.phase = .error(.flowSessionExpired, message: message)
|
||||||
|
scheduleAutoClearError()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showManualOpenHint(path: String) {
|
||||||
|
let msg: String
|
||||||
|
if !hasFullAccess() {
|
||||||
|
msg = ExtL10n.string("keyboard.error.fullAccessForJump")
|
||||||
|
} else if path == "settings" {
|
||||||
|
msg = ExtL10n.string("keyboard.error.manualOpenSettings")
|
||||||
|
} else if path == "startflow" {
|
||||||
|
msg = ExtL10n.string("keyboard.error.manualOpenForFlow")
|
||||||
|
} else {
|
||||||
|
msg = ExtL10n.string("keyboard.error.manualOpenSettings")
|
||||||
|
}
|
||||||
|
state.phase = .error(.manualOpenRequired, message: msg)
|
||||||
|
scheduleAutoClearError()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startFlowRecording() {
|
||||||
|
isPendingFlowStart = false
|
||||||
|
flowStartDeadline = 0
|
||||||
|
stopFlowWatchdog()
|
||||||
|
|
||||||
|
FlowSessionBridge.setTranscriptionLanguage(state.localeId)
|
||||||
|
FlowSessionBridge.setRecordingState(.recording)
|
||||||
|
isFlowRecording = true
|
||||||
|
state.lastTranscript = ""
|
||||||
|
state.phase = .recording
|
||||||
|
if let view = wakeLockView() {
|
||||||
|
ExtensionScreenWakeLock.acquire(from: view)
|
||||||
|
}
|
||||||
|
startUtteranceCountdown()
|
||||||
|
startFlowLevelWatchdog()
|
||||||
|
debug("startFlowRecording")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startUtteranceCountdown() {
|
||||||
|
utteranceStartedAt = Date().timeIntervalSince1970
|
||||||
|
state.utteranceRemainingSeconds = Int(FlowSessionKeys.maxUtteranceDuration)
|
||||||
|
utteranceTimerTask?.cancel()
|
||||||
|
utteranceTimerTask = Task { @MainActor [weak self] in
|
||||||
|
while let self, self.isFlowRecording, !Task.isCancelled {
|
||||||
|
let elapsed = Date().timeIntervalSince1970 - self.utteranceStartedAt
|
||||||
|
let remaining = max(0, Int(ceil(FlowSessionKeys.maxUtteranceDuration - elapsed)))
|
||||||
|
self.state.utteranceRemainingSeconds = remaining
|
||||||
|
if remaining <= 0 {
|
||||||
|
self.pressEnded()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopUtteranceCountdown() {
|
||||||
|
utteranceTimerTask?.cancel()
|
||||||
|
utteranceTimerTask = nil
|
||||||
|
state.utteranceRemainingSeconds = Int(FlowSessionKeys.maxUtteranceDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelPendingFlowStart() {
|
||||||
|
isPendingFlowStart = false
|
||||||
|
flowStartDeadline = 0
|
||||||
|
stopFlowWatchdog()
|
||||||
|
state.phase = .idle
|
||||||
|
state.lastTranscript = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startFlowStartWatchdog() {
|
||||||
|
stopFlowWatchdog()
|
||||||
|
flowWatchdogTask = Task { @MainActor [weak self] in
|
||||||
|
while let self, !Task.isCancelled, self.isPendingFlowStart {
|
||||||
|
if FlowSessionBridge.isSessionActive() {
|
||||||
|
self.completeFlowStartHandoff()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let now = Date().timeIntervalSince1970
|
||||||
|
if self.flowStartDeadline > 0, now > self.flowStartDeadline {
|
||||||
|
self.isPendingFlowStart = false
|
||||||
|
self.flowStartDeadline = 0
|
||||||
|
self.showManualOpenHint(path: "startflow")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try? await Task.sleep(nanoseconds: FlowWatchdog.pollIntervalNs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func completeFlowStartHandoff() {
|
||||||
|
isPendingFlowStart = false
|
||||||
|
flowStartDeadline = 0
|
||||||
|
stopFlowWatchdog()
|
||||||
|
state.lastTranscript = ""
|
||||||
|
refreshSessionState()
|
||||||
|
startFlowRecording()
|
||||||
|
debug("completeFlowStartHandoff → auto startFlowRecording")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startFlowLevelWatchdog() {
|
||||||
|
stopFlowWatchdog()
|
||||||
|
flowWatchdogTask = Task { @MainActor [weak self] in
|
||||||
|
while let self, !Task.isCancelled, self.isFlowRecording {
|
||||||
|
let levels = FlowSessionBridge.audioLevels()
|
||||||
|
if let peak = levels.max(), peak > 0 {
|
||||||
|
self.state.level = Double(peak)
|
||||||
|
}
|
||||||
|
self.refreshFlowPartialIfNeeded()
|
||||||
|
try? await Task.sleep(nanoseconds: FlowWatchdog.pollIntervalNs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshFlowPartialIfNeeded() {
|
||||||
|
guard isFlowRecording || isAwaitingFlowResult else { return }
|
||||||
|
switch state.phase {
|
||||||
|
case .recording, .processing:
|
||||||
|
if let partial = FlowSessionBridge.transcriptionPartial() {
|
||||||
|
state.lastTranscript = partial
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startFlowResultWatchdog() {
|
||||||
|
stopFlowWatchdog()
|
||||||
|
isAwaitingFlowResult = true
|
||||||
|
let startedAt = Date().timeIntervalSince1970
|
||||||
|
let resultTimeout = FlowWatchdog.resultTimeout(engineMode: state.engineMode)
|
||||||
|
flowWatchdogTask = Task { @MainActor [weak self] in
|
||||||
|
while let self, !Task.isCancelled {
|
||||||
|
if let delivery = FlowSessionBridge.consumeTranscriptionDelivery() {
|
||||||
|
self.isAwaitingFlowResult = false
|
||||||
|
self.stopFlowWatchdog()
|
||||||
|
self.textInserter.handleFlowTranscript(delivery)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let error = FlowSessionBridge.consumeTranscriptionError() {
|
||||||
|
self.isAwaitingFlowResult = false
|
||||||
|
self.stopFlowWatchdog()
|
||||||
|
self.state.phase = .error(
|
||||||
|
.fromFlowTranscription(error),
|
||||||
|
message: error.message
|
||||||
|
)
|
||||||
|
self.scheduleAutoClearError()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.refreshFlowPartialIfNeeded()
|
||||||
|
let now = Date().timeIntervalSince1970
|
||||||
|
if now - startedAt > resultTimeout {
|
||||||
|
self.isAwaitingFlowResult = false
|
||||||
|
self.stopFlowWatchdog()
|
||||||
|
let msg = ExtL10n.string("keyboard.flow.resultTimeout")
|
||||||
|
self.state.phase = .error(.flowResultTimeout, message: msg)
|
||||||
|
self.scheduleAutoClearError()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try? await Task.sleep(nanoseconds: FlowWatchdog.pollIntervalNs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopFlowWatchdog() {
|
||||||
|
flowWatchdogTask?.cancel()
|
||||||
|
flowWatchdogTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func debug(_ message: String) {
|
||||||
|
OSGLog.keyboardExt.info("\(message, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// KeyboardTextInserter.swift
|
||||||
|
// OSGKeyboard · Keyboard Extension
|
||||||
|
//
|
||||||
|
// Inserts Flow transcripts from the host app and surfaces polish warnings
|
||||||
|
// without re-running LLM polish in the extension.
|
||||||
|
|
||||||
|
import OSGKeyboardShared
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class KeyboardTextInserter {
|
||||||
|
private let state: KeyboardState
|
||||||
|
private let insertText: (String) -> Void
|
||||||
|
private let scheduleAutoClearError: () -> Void
|
||||||
|
|
||||||
|
init(
|
||||||
|
state: KeyboardState,
|
||||||
|
insertText: @escaping (String) -> Void,
|
||||||
|
scheduleAutoClearError: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.state = state
|
||||||
|
self.insertText = insertText
|
||||||
|
self.scheduleAutoClearError = scheduleAutoClearError
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleFlowTranscript(_ delivery: TranscriptionDelivery) {
|
||||||
|
let trimmed = delivery.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else {
|
||||||
|
state.phase = .idle
|
||||||
|
state.level = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Host app already polished when configured; keyboard only inserts.
|
||||||
|
insertText(trimmed)
|
||||||
|
state.lastTranscript = ""
|
||||||
|
state.level = 0
|
||||||
|
if let warning = delivery.polishWarning {
|
||||||
|
state.phase = .error(.polishDegraded(warning), message: warning)
|
||||||
|
scheduleAutoClearError()
|
||||||
|
} else {
|
||||||
|
state.phase = .idle
|
||||||
|
}
|
||||||
|
OSGLog.keyboardExt.info("flow insert length=\(trimmed.count, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,12 +141,8 @@ public struct KeyboardRootView: View {
|
|||||||
flowSessionActive: state.flowSessionActive,
|
flowSessionActive: state.flowSessionActive,
|
||||||
micDisabled: state.micDisabled,
|
micDisabled: state.micDisabled,
|
||||||
micDisabledHint: state.micDisabledHint,
|
micDisabledHint: state.micDisabledHint,
|
||||||
isLocalEngine: state.isLocalEngine,
|
|
||||||
localModelsReady: state.localModelsReady,
|
|
||||||
localModelsLoaded: state.localModelsLoaded,
|
|
||||||
cursorDragHintActive: state.cursorDragActive,
|
cursorDragHintActive: state.cursorDragActive,
|
||||||
openSettings: state.openSettings,
|
openSettings: state.openSettings
|
||||||
startFlowSession: state.startFlowSession
|
|
||||||
)
|
)
|
||||||
.frame(height: KeyboardLayoutMetrics.transcriptLineHeight)
|
.frame(height: KeyboardLayoutMetrics.transcriptLineHeight)
|
||||||
}
|
}
|
||||||
@@ -337,12 +333,8 @@ private struct TranscriptLine: View {
|
|||||||
let flowSessionActive: Bool
|
let flowSessionActive: Bool
|
||||||
let micDisabled: Bool
|
let micDisabled: Bool
|
||||||
let micDisabledHint: String
|
let micDisabledHint: String
|
||||||
let isLocalEngine: Bool
|
|
||||||
let localModelsReady: Bool
|
|
||||||
let localModelsLoaded: Bool
|
|
||||||
let cursorDragHintActive: Bool
|
let cursorDragHintActive: Bool
|
||||||
let openSettings: () -> Void
|
let openSettings: () -> Void
|
||||||
let startFlowSession: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -367,39 +359,14 @@ private struct TranscriptLine: View {
|
|||||||
.foregroundStyle(palette.warning)
|
.foregroundStyle(palette.warning)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
} else if isLocalEngine, !localModelsReady {
|
|
||||||
Button(action: openSettings) {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Text(ExtL10n.string("keyboard.models.notDownloaded"))
|
|
||||||
Image(systemName: "chevron.right")
|
|
||||||
.font(.system(size: 10, weight: .semibold))
|
|
||||||
}
|
|
||||||
.font(TypeStyle.caption)
|
|
||||||
.foregroundStyle(palette.warning)
|
|
||||||
.lineLimit(1)
|
|
||||||
.truncationMode(.tail)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityHint(ExtL10n.text("keyboard.models.downloadHint"))
|
|
||||||
} else if flowSessionActive {
|
} else if flowSessionActive {
|
||||||
ExtL10n.text("keyboard.placeholder.idle")
|
ExtL10n.text("keyboard.placeholder.idle")
|
||||||
.font(TypeStyle.caption)
|
.font(TypeStyle.caption)
|
||||||
.foregroundStyle(palette.textTertiary)
|
.foregroundStyle(palette.textTertiary)
|
||||||
} else {
|
} else {
|
||||||
HStack(spacing: 6) {
|
ExtL10n.text("keyboard.flow.sessionInactive")
|
||||||
ExtL10n.text("keyboard.flow.sessionInactive")
|
.font(TypeStyle.caption)
|
||||||
.font(TypeStyle.caption)
|
.foregroundStyle(palette.textTertiary)
|
||||||
.foregroundStyle(palette.textTertiary)
|
|
||||||
Button(action: startFlowSession) {
|
|
||||||
ExtL10n.text("keyboard.flow.start")
|
|
||||||
.font(TypeStyle.caption)
|
|
||||||
.foregroundStyle(palette.accent)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityHint(ExtL10n.text("keyboard.flow.startA11y"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
case .requestingPermissions:
|
case .requestingPermissions:
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
// TranslationChip.swift
|
|
||||||
// OSGKeyboard · Keyboard Extension
|
|
||||||
//
|
|
||||||
// Compact chip rendered to the right of `LocaleChip` on the keyboard
|
|
||||||
// top bar. Doubles as both the on/off switch and the target-language
|
|
||||||
// picker — same Menu pattern as `LocaleChip` so muscle memory transfers.
|
|
||||||
//
|
|
||||||
// v0.2.1 follow-up: removed the explicit on/off toggle entry. The
|
|
||||||
// chip is now a pure picker over the 11 catalog rows (off + 10
|
|
||||||
// locales); selecting "不翻译" turns translation off, selecting any
|
|
||||||
// locale turns it on with that target. `translationEnabled` is
|
|
||||||
// derived from the locale id so the chip / pipeline read the same
|
|
||||||
// source of truth.
|
|
||||||
//
|
|
||||||
// v0.2.1 final review: dropped the "needs cloud" warning state —
|
|
||||||
// both engines now run the translate-and-polish step (the local
|
|
||||||
// engine routes through DeepSeek via
|
|
||||||
// `ProviderConfig.localModeProviderId`). The chip is therefore just
|
|
||||||
// off / on, with the same accent treatment either way.
|
|
||||||
//
|
|
||||||
// Visual states:
|
|
||||||
// • off → dim outline, "翻译" chip label (menu first row = "不翻译")
|
|
||||||
// • on (any engine) → accent fill, "→ EN" / "→ 日本語" style label
|
|
||||||
//
|
|
||||||
// Stays in the same visual family as `CloudEngineChip` / `LocaleChip`
|
|
||||||
// (Capsule + 28 pt min height + 6 pt vertical padding) so the top bar
|
|
||||||
// doesn't grow when translation is enabled.
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
|
||||||
|
|
||||||
struct TranslationChip: View, Equatable {
|
|
||||||
/// Passed in as a value (not read from `@Environment`) so the chip can
|
|
||||||
/// be wrapped in `.equatable()` at the call site: `EquatableView`
|
|
||||||
/// suppresses environment-driven refreshes, so injecting the palette
|
|
||||||
/// here keeps colours correct across dark/light switches.
|
|
||||||
let palette: ThemePalette
|
|
||||||
/// The active target-locale id (`offLocaleId` == translation off).
|
|
||||||
let targetLocaleId: String
|
|
||||||
/// Writes the picked locale id — wired to `state.setTranslationTargetLocaleId`.
|
|
||||||
let onSelect: (String) -> Void
|
|
||||||
|
|
||||||
/// Only `palette` and `targetLocaleId` drive the visuals; the
|
|
||||||
/// `onSelect` closure is deliberately excluded from equality. Because
|
|
||||||
/// the keyboard polls the App Group at 1 Hz (each poll re-publishes the
|
|
||||||
/// `KeyboardState`), the parent view re-renders every second. Without
|
|
||||||
/// this, SwiftUI would rebuild the `Menu` on every poll — dismissing an
|
|
||||||
/// open picker or snapping its scroll position back to the top. With
|
|
||||||
/// `.equatable()` the picker is rebuilt only on a real state change.
|
|
||||||
nonisolated static func == (lhs: TranslationChip, rhs: TranslationChip) -> Bool {
|
|
||||||
lhs.palette == rhs.palette && lhs.targetLocaleId == rhs.targetLocaleId
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Menu {
|
|
||||||
// v0.2.1 follow-up: pure picker over the full catalog,
|
|
||||||
// including `offLocaleId` at the top so "turn off" is one
|
|
||||||
// tap from any enabled state. Picking a row writes
|
|
||||||
// `translationTargetLocaleId`; `translationEnabled` is
|
|
||||||
// derived from it.
|
|
||||||
ForEach(TranslationLanguageCatalog.all) { language in
|
|
||||||
Button {
|
|
||||||
onSelect(language.id)
|
|
||||||
} label: {
|
|
||||||
if language.id == targetLocaleId {
|
|
||||||
Label(displayLabel(for: language), systemImage: "checkmark")
|
|
||||||
} else {
|
|
||||||
Text(displayLabel(for: language))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
label
|
|
||||||
}
|
|
||||||
.menuStyle(.button)
|
|
||||||
.accessibilityLabel(ExtL10n.text("keyboard.translation.a11y"))
|
|
||||||
.accessibilityHint(ExtL10n.text("keyboard.translation.a11yHint"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
private var label: some View {
|
|
||||||
let target = TranslationLanguageCatalog.resolve(targetLocaleId)
|
|
||||||
let enabled = targetLocaleId != TranslationLanguageCatalog.offLocaleId
|
|
||||||
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: enabled ? "character.bubble" : "character.bubble.fill")
|
|
||||||
Text(chipLabel(target: target, enabled: enabled))
|
|
||||||
Image(systemName: "chevron.down")
|
|
||||||
.font(.system(size: 8, weight: .bold))
|
|
||||||
}
|
|
||||||
.font(TypeStyle.caption2)
|
|
||||||
.foregroundStyle(foreground(enabled: enabled))
|
|
||||||
.padding(.horizontal, Spacing.xs + 2)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.frame(minHeight: 28)
|
|
||||||
.background(background(enabled: enabled), in: Capsule())
|
|
||||||
.overlay(Capsule().stroke(stroke(enabled: enabled), lineWidth: 0.5))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func displayLabel(for language: TranslationLanguage) -> String {
|
|
||||||
if language.id == TranslationLanguageCatalog.offLocaleId {
|
|
||||||
return ExtL10n.string("keyboard.translation.offMenu")
|
|
||||||
}
|
|
||||||
return language.nativeName
|
|
||||||
}
|
|
||||||
|
|
||||||
private func chipLabel(target: TranslationLanguage, enabled: Bool) -> String {
|
|
||||||
if !enabled {
|
|
||||||
return ExtL10n.string("keyboard.translation.chip")
|
|
||||||
}
|
|
||||||
// Short form: "→EN" / "→日" style. Falls back to the prompt
|
|
||||||
// language name for languages without a chip-style abbreviation
|
|
||||||
// (e.g. French → "FR" via the 2-letter prefix).
|
|
||||||
let short = shortLabel(for: target)
|
|
||||||
return "→\(short)"
|
|
||||||
}
|
|
||||||
|
|
||||||
private func shortLabel(for target: TranslationLanguage) -> String {
|
|
||||||
switch target.id {
|
|
||||||
case "en": return "EN"
|
|
||||||
case "zh-Hans": return "中"
|
|
||||||
case "zh-Hant": return "繁"
|
|
||||||
case "ja": return "日"
|
|
||||||
case "ko": return "韩"
|
|
||||||
case "fr": return "FR"
|
|
||||||
case "de": return "DE"
|
|
||||||
case "es": return "ES"
|
|
||||||
case "ru": return "RU"
|
|
||||||
case "pt": return "PT"
|
|
||||||
default: return target.promptLanguageName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func foreground(enabled: Bool) -> Color {
|
|
||||||
if enabled { return palette.accent }
|
|
||||||
return palette.textPrimary
|
|
||||||
}
|
|
||||||
|
|
||||||
private func background(enabled: Bool) -> Color {
|
|
||||||
if enabled { return palette.accent.opacity(0.15) }
|
|
||||||
return palette.surfaceElevated
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stroke(enabled: Bool) -> Color {
|
|
||||||
if enabled { return palette.accent.opacity(0.35) }
|
|
||||||
return palette.divider
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -60,23 +60,61 @@ final class KeyboardStateTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func testModeSwitchFromPolishToOff() {
|
func testFlowStructuredErrorKinds() {
|
||||||
let s = KeyboardState()
|
let s = KeyboardState()
|
||||||
XCTAssertEqual(s.mode, .polish)
|
s.phase = .error(.manualOpenRequired, message: "open app")
|
||||||
s.mode = .off
|
if case .error(.manualOpenRequired, let msg) = s.phase {
|
||||||
XCTAssertEqual(s.mode, .off)
|
XCTAssertEqual(msg, "open app")
|
||||||
s.mode = .transcribe
|
} else {
|
||||||
XCTAssertEqual(s.mode, .transcribe)
|
XCTFail("expected manualOpenRequired")
|
||||||
s.mode = .polish
|
}
|
||||||
XCTAssertEqual(s.mode, .polish)
|
|
||||||
|
s.phase = .error(.polishDegraded("warn"), message: "warn")
|
||||||
|
if case .error(.polishDegraded("warn"), _) = s.phase {} else {
|
||||||
|
XCTFail("expected polishDegraded")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.phase = .error(.flowResultTimeout, message: "timeout")
|
||||||
|
if case .error(.flowResultTimeout, _) = s.phase {} else {
|
||||||
|
XCTFail("expected flowResultTimeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.phase = .error(.flowSessionExpired, message: "expired")
|
||||||
|
if case .error(.flowSessionExpired, _) = s.phase {} else {
|
||||||
|
XCTFail("expected flowSessionExpired")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.phase = .error(.fullAccessRequired, message: "full access")
|
||||||
|
if case .error(.fullAccessRequired, _) = s.phase {} else {
|
||||||
|
XCTFail("expected fullAccessRequired")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.phase = .error(.noSpeechDetected, message: "no speech")
|
||||||
|
if case .error(.noSpeechDetected, _) = s.phase {} else {
|
||||||
|
XCTFail("expected noSpeechDetected")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.phase = .error(.recognitionInterrupted, message: "interrupted")
|
||||||
|
if case .error(.recognitionInterrupted, _) = s.phase {} else {
|
||||||
|
XCTFail("expected recognitionInterrupted")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.phase = .error(.hostAudioUnavailable, message: "audio")
|
||||||
|
if case .error(.hostAudioUnavailable, _) = s.phase {} else {
|
||||||
|
XCTFail("expected hostAudioUnavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
let flowError = FlowTranscriptionError(message: "asr failed", kind: .asrFailed)
|
||||||
|
XCTAssertEqual(
|
||||||
|
KeyboardState.Phase.ErrorKind.fromFlowTranscription(flowError),
|
||||||
|
.hostTranscriptionFailed("asr failed")
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testInputModeRoundTripsThroughRawValue() {
|
func testInputModeIsPolishOnly() {
|
||||||
// The mode is persisted by rawValue (see `AppGroupStore.setModeId`)
|
let s = KeyboardState()
|
||||||
// so the round-trip is part of the public contract.
|
XCTAssertEqual(s.mode, .polish)
|
||||||
for mode in KeyboardState.InputMode.allCases {
|
XCTAssertEqual(KeyboardState.InputMode.allCases, [.polish])
|
||||||
let raw = mode.rawValue
|
XCTAssertEqual(KeyboardState.InputMode(rawValue: "polish"), .polish)
|
||||||
XCTAssertNotNil(KeyboardState.InputMode(rawValue: raw))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "OSGLogo.svg",
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
},
|
||||||
|
"properties" : {
|
||||||
|
"preserves-vector-representation" : true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M627.511 512.419C627.511 448.783 576.127 397.197 512.741 397.197C449.355 397.197 397.971 448.783 397.971 512.419C397.971 576.054 449.355 627.641 512.741 627.641V637.837C443.746 637.837 387.814 581.686 387.814 512.419C387.814 443.152 443.746 387 512.741 387C581.736 387 637.668 443.152 637.668 512.419C637.668 581.686 581.736 637.837 512.741 637.837V627.641C576.127 627.641 627.511 576.054 627.511 512.419Z" fill="white"/>
|
||||||
|
<path d="M56.2632 512.419C56.2632 581.686 112.195 637.837 181.19 637.837C250.185 637.837 306.117 581.686 306.117 512.419C306.117 443.152 250.185 387 181.19 387C112.195 387 56.2632 443.152 56.2632 512.419Z" fill="white"/>
|
||||||
|
<path d="M399.618 562.094L399.618 552.257L512.74 552.257L512.74 562.094L399.618 562.094Z" fill="white"/>
|
||||||
|
<path d="M512.741 483.398V473.561H625.864V483.398H512.741Z" fill="white"/>
|
||||||
|
<path d="M843.308 387.981C910.987 387.981 966.093 441.8 968.171 508.975H843.308V518.812H968.096C965.014 585.066 910.324 637.836 843.308 637.836C774.313 637.836 718.381 581.904 718.381 512.909C718.381 443.914 774.312 387.981 843.308 387.981ZM968.234 518.812H968.096C968.187 516.855 968.234 514.888 968.234 512.909C968.234 511.593 968.211 510.281 968.171 508.975H968.234V518.812Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
// FlowActivityAttributes.swift
|
||||||
|
// OSGKeyboard · Live Activity
|
||||||
|
//
|
||||||
|
// Shared ActivityKit model compiled into the widget extension and the
|
||||||
|
// main app so `FlowLiveActivityController` can start/update sessions.
|
||||||
|
|
||||||
|
import ActivityKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Live Activity shown in the Dynamic Island while a Flow session is active.
|
||||||
|
struct FlowActivityAttributes: ActivityAttributes {
|
||||||
|
/// Dynamic content updated as the user records and processes speech.
|
||||||
|
struct ContentState: Codable, Hashable, Sendable {
|
||||||
|
var phase: Phase
|
||||||
|
|
||||||
|
enum Phase: String, Codable, Hashable, Sendable {
|
||||||
|
case idle
|
||||||
|
case recording
|
||||||
|
case processing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// FlowLiveActivityWidget.swift
|
||||||
|
// OSGKeyboard · Live Activity
|
||||||
|
//
|
||||||
|
// Dynamic Island compact leading shows the OSGKeyboard mark instead of the
|
||||||
|
// generic system microphone glyph that appears without a Live Activity.
|
||||||
|
|
||||||
|
import ActivityKit
|
||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
struct FlowLiveActivityWidget: Widget {
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
ActivityConfiguration(for: FlowActivityAttributes.self) { context in
|
||||||
|
FlowLiveActivityLockScreenView(phase: context.state.phase)
|
||||||
|
.activityBackgroundTint(Color.black.opacity(0.82))
|
||||||
|
.activitySystemActionForegroundColor(.white)
|
||||||
|
} dynamicIsland: { context in
|
||||||
|
DynamicIsland {
|
||||||
|
DynamicIslandExpandedRegion(.leading) {
|
||||||
|
FlowLiveActivityBrandMark(size: 28)
|
||||||
|
}
|
||||||
|
DynamicIslandExpandedRegion(.trailing) {
|
||||||
|
FlowLiveActivityPhaseLabel(phase: context.state.phase)
|
||||||
|
}
|
||||||
|
DynamicIslandExpandedRegion(.center) {
|
||||||
|
Text("OSGKeyboard")
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
DynamicIslandExpandedRegion(.bottom) {
|
||||||
|
FlowLiveActivityPhaseCaption(phase: context.state.phase)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} compactLeading: {
|
||||||
|
FlowLiveActivityBrandMark(size: 22)
|
||||||
|
} compactTrailing: {
|
||||||
|
FlowLiveActivityTrailingGlyph(phase: context.state.phase)
|
||||||
|
} minimal: {
|
||||||
|
FlowLiveActivityBrandMark(size: 18)
|
||||||
|
}
|
||||||
|
.keylineTint(Color(red: 0.35, green: 0.55, blue: 1.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Views
|
||||||
|
|
||||||
|
private struct FlowLiveActivityLockScreenView: View {
|
||||||
|
let phase: FlowActivityAttributes.ContentState.Phase
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
FlowLiveActivityBrandMark(size: 32)
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("OSGKeyboard")
|
||||||
|
.font(.headline)
|
||||||
|
FlowLiveActivityPhaseCaption(phase: phase)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
FlowLiveActivityTrailingGlyph(phase: phase)
|
||||||
|
}
|
||||||
|
// iOS Live Activity lock-screen content needs margins so the leading
|
||||||
|
// logo and trailing glyph don't touch the card edges.
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Branded mark used in compactLeading so users see OSGKeyboard, not the system mic icon.
|
||||||
|
/// Transparent white OSG glyphs render directly on the black Dynamic Island.
|
||||||
|
private struct FlowLiveActivityBrandMark: View {
|
||||||
|
let size: CGFloat
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Image("OSGLogo")
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.accessibilityLabel("OSGKeyboard")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct FlowLiveActivityTrailingGlyph: View {
|
||||||
|
let phase: FlowActivityAttributes.ContentState.Phase
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch phase {
|
||||||
|
case .recording:
|
||||||
|
Image(systemName: "waveform")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.symbolEffect(.variableColor.iterative, options: .repeating)
|
||||||
|
case .processing:
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
.tint(.white)
|
||||||
|
case .idle:
|
||||||
|
// Session ready but NOT listening — avoid a mic glyph so users
|
||||||
|
// don't think the keyboard is recording in the background.
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct FlowLiveActivityPhaseLabel: View {
|
||||||
|
let phase: FlowActivityAttributes.ContentState.Phase
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch phase {
|
||||||
|
case .recording:
|
||||||
|
Text("REC")
|
||||||
|
.font(.caption.monospacedDigit().weight(.bold))
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
case .processing:
|
||||||
|
Text("…")
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
case .idle:
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct FlowLiveActivityPhaseCaption: View {
|
||||||
|
let phase: FlowActivityAttributes.ContentState.Phase
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch phase {
|
||||||
|
case .idle:
|
||||||
|
Text("Voice session active")
|
||||||
|
case .recording:
|
||||||
|
Text("Listening…")
|
||||||
|
case .processing:
|
||||||
|
Text("Transcribing…")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>OSGKeyboardLiveActivity</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>XPC!</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.widgetkit-extension</string>
|
||||||
|
</dict>
|
||||||
|
<key>NSSupportsLiveActivities</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// OSGKeyboardLiveActivityBundle.swift
|
||||||
|
// OSGKeyboard · Live Activity
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct OSGKeyboardLiveActivityBundle: WidgetBundle {
|
||||||
|
var body: some Widget {
|
||||||
|
FlowLiveActivityWidget()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,15 @@ public enum AppGroup {
|
|||||||
) != nil
|
) != nil
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
/// Shared UserDefaults when the App Group suite is available; `nil` otherwise.
|
||||||
|
///
|
||||||
|
/// Prefer this in Release builds and in the keyboard extension so callers
|
||||||
|
/// can surface a setup error instead of silently reading/writing the wrong suite.
|
||||||
|
public static var defaultsIfAvailable: UserDefaults? {
|
||||||
|
guard isAvailable else { return nil }
|
||||||
|
return UserDefaults(suiteName: identifier)
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared UserDefaults instance for cross-process config.
|
/// Shared UserDefaults instance for cross-process config.
|
||||||
///
|
///
|
||||||
/// In DEBUG builds a missing App Group is a hard `fatalError`: silently
|
/// In DEBUG builds a missing App Group is a hard `fatalError`: silently
|
||||||
@@ -32,33 +41,30 @@ public enum AppGroup {
|
|||||||
/// the App an API key and nothing happens" — which is exactly the bug
|
/// the App an API key and nothing happens" — which is exactly the bug
|
||||||
/// this is meant to prevent.
|
/// this is meant to prevent.
|
||||||
///
|
///
|
||||||
/// In release builds we keep the soft fallback + `NSLog` so an
|
/// In Release builds there is **no** `.standard` fallback — use
|
||||||
/// end-user whose developer account simply lacks the App Group still
|
/// `defaultsIfAvailable` and handle `nil` when provisioning is missing.
|
||||||
/// gets a usable main App (the keyboard extension won't work, but at
|
|
||||||
/// least the App doesn't crash on launch).
|
|
||||||
public static var defaults: UserDefaults {
|
public static var defaults: UserDefaults {
|
||||||
if let d = UserDefaults(suiteName: identifier) {
|
guard let suite = defaultsIfAvailable else {
|
||||||
return d
|
#if DEBUG
|
||||||
|
fatalError("""
|
||||||
|
⚠️ App Group \(identifier) unavailable.
|
||||||
|
|
||||||
|
Add the App Group in:
|
||||||
|
1. Apple Developer portal → Identifiers → App Groups → add
|
||||||
|
\(identifier)
|
||||||
|
2. Both bundle IDs (main app + keyboard extension) → enable
|
||||||
|
that App Group under Capabilities
|
||||||
|
3. Re-generate the provisioning profile, download it, and
|
||||||
|
re-run the project.
|
||||||
|
|
||||||
|
Falling back to .standard would silently desync the keyboard
|
||||||
|
extension from the main App — a hard crash in DEBUG is the
|
||||||
|
only way to make the misconfiguration impossible to miss.
|
||||||
|
""")
|
||||||
|
#else
|
||||||
|
fatalError("App Group \(identifier) unavailable. Check entitlements and provisioning.")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
#if DEBUG
|
return suite
|
||||||
fatalError("""
|
|
||||||
⚠️ App Group \(identifier) unavailable.
|
|
||||||
|
|
||||||
Add the App Group in:
|
|
||||||
1. Apple Developer portal → Identifiers → App Groups → add
|
|
||||||
\(identifier)
|
|
||||||
2. Both bundle IDs (main app + keyboard extension) → enable
|
|
||||||
that App Group under Capabilities
|
|
||||||
3. Re-generate the provisioning profile, download it, and
|
|
||||||
re-run the project.
|
|
||||||
|
|
||||||
Falling back to .standard would silently desync the keyboard
|
|
||||||
extension from the main App — a hard crash in DEBUG is the
|
|
||||||
only way to make the misconfiguration impossible to miss.
|
|
||||||
""")
|
|
||||||
#else
|
|
||||||
NSLog("⚠️ [OSGKeyboard] App Group \(identifier) unavailable, falling back to .standard. The keyboard extension will not see config written by the main app.")
|
|
||||||
return .standard
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-33
@@ -1,32 +1,30 @@
|
|||||||
// RecordButton.swift
|
// RecordButton.swift
|
||||||
// OSGKeyboard · Keyboard Extension
|
// OSGKeyboard · Shared
|
||||||
//
|
//
|
||||||
// Tap-to-toggle mic: tap once to start, tap again to stop. Shows a
|
// Tap-to-toggle mic button shared between the keyboard extension and
|
||||||
// remaining-time countdown while recording; last 10 seconds turn red.
|
// host-app keyboard preview surfaces.
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import OSGKeyboardShared
|
|
||||||
|
|
||||||
struct RecordButton: View {
|
public struct RecordButton: View {
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
|
|
||||||
enum Phase: Equatable {
|
public enum Phase: Equatable {
|
||||||
case idle
|
case idle
|
||||||
case recording
|
case recording
|
||||||
case processing
|
case processing
|
||||||
case error
|
case error
|
||||||
}
|
}
|
||||||
|
|
||||||
let phase: Phase
|
public let phase: Phase
|
||||||
let level: Double // 0...1
|
public let level: Double
|
||||||
/// Seconds left in the current utterance; shown only while recording.
|
public let remainingSeconds: Int?
|
||||||
let remainingSeconds: Int?
|
public let isEnabled: Bool
|
||||||
let isEnabled: Bool
|
public let onToggle: () -> Void
|
||||||
let onToggle: () -> Void
|
|
||||||
|
|
||||||
@State private var breath: Bool = false
|
@State private var breath = false
|
||||||
|
|
||||||
init(
|
public init(
|
||||||
phase: Phase,
|
phase: Phase,
|
||||||
level: Double,
|
level: Double,
|
||||||
remainingSeconds: Int? = nil,
|
remainingSeconds: Int? = nil,
|
||||||
@@ -45,8 +43,6 @@ struct RecordButton: View {
|
|||||||
return remainingSeconds <= 10
|
return remainingSeconds <= 10
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decorative rings are sized to stay inside the 121 pt frame applied
|
|
||||||
/// by `KeyboardRootView` so glow / breath animations are not clipped.
|
|
||||||
private enum Layout {
|
private enum Layout {
|
||||||
static let disc: CGFloat = 95
|
static let disc: CGFloat = 95
|
||||||
static let outerRing: CGFloat = 106
|
static let outerRing: CGFloat = 106
|
||||||
@@ -54,7 +50,7 @@ struct RecordButton: View {
|
|||||||
static let glow: CGFloat = 119
|
static let glow: CGFloat = 119
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
public var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
.stroke(palette.recordRed.opacity(isUrgent ? 0.55 : 0.35), lineWidth: isUrgent ? 3 : 2)
|
.stroke(palette.recordRed.opacity(isUrgent ? 0.55 : 0.35), lineWidth: isUrgent ? 3 : 2)
|
||||||
@@ -79,10 +75,7 @@ struct RecordButton: View {
|
|||||||
.animation(Motion.soft, value: level)
|
.animation(Motion.soft, value: level)
|
||||||
|
|
||||||
Circle()
|
Circle()
|
||||||
.stroke(
|
.stroke(Color.white.opacity(phase == .idle ? 0.08 : 0.12), lineWidth: 0.5)
|
||||||
Color.white.opacity(phase == .idle ? 0.08 : 0.12),
|
|
||||||
lineWidth: 0.5
|
|
||||||
)
|
|
||||||
.frame(width: Layout.outerRing, height: Layout.outerRing)
|
.frame(width: Layout.outerRing, height: Layout.outerRing)
|
||||||
|
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -106,7 +99,6 @@ struct RecordButton: View {
|
|||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.contentTransition(.numericText())
|
.contentTransition(.numericText())
|
||||||
// 倒计时略下移,与波形一起在圆盘内更居中。
|
|
||||||
.offset(y: 3)
|
.offset(y: 3)
|
||||||
}
|
}
|
||||||
WaveformView(
|
WaveformView(
|
||||||
@@ -114,9 +106,9 @@ struct RecordButton: View {
|
|||||||
color: Color(red: 1.0, green: 0.78, blue: 0.78),
|
color: Color(red: 1.0, green: 0.78, blue: 0.78),
|
||||||
active: true
|
active: true
|
||||||
)
|
)
|
||||||
.frame(width: 73, height: 32)
|
.frame(width: 73, height: 32)
|
||||||
.opacity(0.4)
|
.opacity(0.4)
|
||||||
.scaleEffect(0.96)
|
.scaleEffect(0.96)
|
||||||
}
|
}
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
case .processing:
|
case .processing:
|
||||||
@@ -145,13 +137,13 @@ struct RecordButton: View {
|
|||||||
.onChange(of: phase) { _, new in
|
.onChange(of: phase) { _, new in
|
||||||
breath = (new == .recording)
|
breath = (new == .recording)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(ExtL10n.text("keyboard.tapToTalkA11y"))
|
.accessibilityLabel(Text(SharedL10n.string("keyboard.tapToTalkA11y")))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatRemaining(_ seconds: Int) -> String {
|
private func formatRemaining(_ seconds: Int) -> String {
|
||||||
let m = seconds / 60
|
let minutes = seconds / 60
|
||||||
let s = seconds % 60
|
let remainder = seconds % 60
|
||||||
return String(format: "%d:%02d", m, s)
|
return String(format: "%d:%02d", minutes, remainder)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var discGradient: LinearGradient {
|
private var discGradient: LinearGradient {
|
||||||
@@ -175,10 +167,7 @@ struct RecordButton: View {
|
|||||||
)
|
)
|
||||||
case .idle:
|
case .idle:
|
||||||
return LinearGradient(
|
return LinearGradient(
|
||||||
colors: [
|
colors: [palette.accent.opacity(0.95), palette.accent.opacity(0.75)],
|
||||||
palette.accent.opacity(0.95),
|
|
||||||
palette.accent.opacity(0.75)
|
|
||||||
],
|
|
||||||
startPoint: .top,
|
startPoint: .top,
|
||||||
endPoint: .bottom
|
endPoint: .bottom
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// TranslationChip.swift
|
||||||
|
// OSGKeyboard · Shared
|
||||||
|
//
|
||||||
|
// Translation target picker chip shared between keyboard extension and
|
||||||
|
// host-app preview surfaces.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public struct TranslationChip: View, Equatable {
|
||||||
|
public let palette: ThemePalette
|
||||||
|
public let targetLocaleId: String
|
||||||
|
public let onSelect: (String) -> Void
|
||||||
|
|
||||||
|
public init(
|
||||||
|
palette: ThemePalette,
|
||||||
|
targetLocaleId: String,
|
||||||
|
onSelect: @escaping (String) -> Void
|
||||||
|
) {
|
||||||
|
self.palette = palette
|
||||||
|
self.targetLocaleId = targetLocaleId
|
||||||
|
self.onSelect = onSelect
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated public static func == (lhs: TranslationChip, rhs: TranslationChip) -> Bool {
|
||||||
|
lhs.palette == rhs.palette && lhs.targetLocaleId == rhs.targetLocaleId
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
Menu {
|
||||||
|
ForEach(TranslationLanguageCatalog.all) { language in
|
||||||
|
Button {
|
||||||
|
onSelect(language.id)
|
||||||
|
} label: {
|
||||||
|
if language.id == targetLocaleId {
|
||||||
|
Label(displayLabel(for: language), systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(displayLabel(for: language))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
label
|
||||||
|
}
|
||||||
|
.menuStyle(.button)
|
||||||
|
.accessibilityLabel(Text(SharedL10n.string("keyboard.translation.a11y")))
|
||||||
|
.accessibilityHint(Text(SharedL10n.string("keyboard.translation.a11yHint")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var label: some View {
|
||||||
|
let target = TranslationLanguageCatalog.resolve(targetLocaleId)
|
||||||
|
let enabled = targetLocaleId != TranslationLanguageCatalog.offLocaleId
|
||||||
|
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: enabled ? "character.bubble" : "character.bubble.fill")
|
||||||
|
Text(chipLabel(target: target, enabled: enabled))
|
||||||
|
Image(systemName: "chevron.down")
|
||||||
|
.font(.system(size: 8, weight: .bold))
|
||||||
|
}
|
||||||
|
.font(TypeStyle.caption2)
|
||||||
|
.foregroundStyle(foreground(enabled: enabled))
|
||||||
|
.padding(.horizontal, Spacing.xs + 2)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.frame(minHeight: 28)
|
||||||
|
.background(background(enabled: enabled), in: Capsule())
|
||||||
|
.overlay(Capsule().stroke(stroke(enabled: enabled), lineWidth: 0.5))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func displayLabel(for language: TranslationLanguage) -> String {
|
||||||
|
if language.id == TranslationLanguageCatalog.offLocaleId {
|
||||||
|
return SharedL10n.string("keyboard.translation.offMenu")
|
||||||
|
}
|
||||||
|
return language.nativeName
|
||||||
|
}
|
||||||
|
|
||||||
|
private func chipLabel(target: TranslationLanguage, enabled: Bool) -> String {
|
||||||
|
if !enabled {
|
||||||
|
return SharedL10n.string("keyboard.translation.chip")
|
||||||
|
}
|
||||||
|
return "→\(shortLabel(for: target))"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shortLabel(for target: TranslationLanguage) -> String {
|
||||||
|
switch target.id {
|
||||||
|
case "en": return "EN"
|
||||||
|
case "zh-Hans": return "中"
|
||||||
|
case "zh-Hant": return "繁"
|
||||||
|
case "ja": return "日"
|
||||||
|
case "ko": return "韩"
|
||||||
|
case "fr": return "FR"
|
||||||
|
case "de": return "DE"
|
||||||
|
case "es": return "ES"
|
||||||
|
case "ru": return "RU"
|
||||||
|
case "pt": return "PT"
|
||||||
|
default: return target.promptLanguageName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func foreground(enabled: Bool) -> Color {
|
||||||
|
enabled ? palette.accent : palette.textPrimary
|
||||||
|
}
|
||||||
|
|
||||||
|
private func background(enabled: Bool) -> Color {
|
||||||
|
enabled ? palette.accent.opacity(0.15) : palette.surfaceElevated
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stroke(enabled: Bool) -> Color {
|
||||||
|
enabled ? palette.accent.opacity(0.35) : palette.divider
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-16
@@ -1,23 +1,20 @@
|
|||||||
// WaveformView.swift
|
// WaveformView.swift
|
||||||
// OSGKeyboard · Keyboard Extension
|
// OSGKeyboard · Shared
|
||||||
//
|
//
|
||||||
// Symmetric, real-time driven waveform. 18 bars centred around a vertical
|
// Symmetric, real-time driven waveform. Shared between the keyboard
|
||||||
// axis. The dominant bar is driven by the current RMS; surrounding bars
|
// extension and any host-app preview that mirrors the mic UI.
|
||||||
// decay on a small position-based curve so the visual feels like a
|
|
||||||
// horizontal speaker cone, not random noise.
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import OSGKeyboardShared
|
|
||||||
|
|
||||||
struct WaveformView: View {
|
public struct WaveformView: View {
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
|
|
||||||
let level: Double // 0...1, smoothed RMS
|
public let level: Double
|
||||||
let barCount: Int
|
public let barCount: Int
|
||||||
let color: Color?
|
public let color: Color?
|
||||||
let active: Bool // when false, bars collapse to a thin resting line
|
public let active: Bool
|
||||||
|
|
||||||
init(
|
public init(
|
||||||
level: Double,
|
level: Double,
|
||||||
barCount: Int = 18,
|
barCount: Int = 18,
|
||||||
color: Color? = nil,
|
color: Color? = nil,
|
||||||
@@ -33,13 +30,16 @@ struct WaveformView: View {
|
|||||||
color ?? palette.recordRed
|
color ?? palette.recordRed
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
public var body: some View {
|
||||||
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
|
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
|
||||||
HStack(alignment: .center, spacing: 3) {
|
HStack(alignment: .center, spacing: 3) {
|
||||||
ForEach(0..<barCount, id: \.self) { i in
|
ForEach(0..<barCount, id: \.self) { index in
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(resolvedColor)
|
.fill(resolvedColor)
|
||||||
.frame(width: 2.4, height: height(for: i, time: context.date.timeIntervalSinceReferenceDate))
|
.frame(
|
||||||
|
width: 2.4,
|
||||||
|
height: height(for: index, time: context.date.timeIntervalSinceReferenceDate)
|
||||||
|
)
|
||||||
.opacity(active ? 1.0 : 0.45)
|
.opacity(active ? 1.0 : 0.45)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,6 @@ struct WaveformView: View {
|
|||||||
guard active else { return 4 }
|
guard active else { return 4 }
|
||||||
let centre = Double(barCount - 1) / 2.0
|
let centre = Double(barCount - 1) / 2.0
|
||||||
let distance = abs(Double(index) - centre) / max(centre, 1)
|
let distance = abs(Double(index) - centre) / max(centre, 1)
|
||||||
// Per-bar small wobble so the line is alive but tied to level.
|
|
||||||
let phase = sin(time * 4.0 + Double(index) * 0.45)
|
let phase = sin(time * 4.0 + Double(index) * 0.45)
|
||||||
let wobble = 0.18 * phase
|
let wobble = 0.18 * phase
|
||||||
let magnitude = max(0, min(1, Double(level) + wobble))
|
let magnitude = max(0, min(1, Double(level) + wobble))
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
// AppGroupConfiguration.swift
|
||||||
|
// OSGKeyboard · Shared
|
||||||
|
//
|
||||||
|
// Single source of truth for App Group UserDefaults keys (`config.*`).
|
||||||
|
// Both `ProviderConfig` (main app) and `AppGroupStore` (keyboard ext)
|
||||||
|
// should read/write through this type so keys and defaults stay aligned.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct AppGroupConfiguration: Sendable, Equatable {
|
||||||
|
// MARK: - Keys
|
||||||
|
|
||||||
|
public enum Keys {
|
||||||
|
public static let providerId = "config.providerId"
|
||||||
|
public static let baseURL = "config.baseURL"
|
||||||
|
/// Legacy plaintext slot — migrated to Keychain on first read.
|
||||||
|
public static let apiKeyLegacy = "config.apiKey"
|
||||||
|
public static let model = "config.model"
|
||||||
|
public static let modeId = "config.modeId"
|
||||||
|
public static let localeId = "config.localeId"
|
||||||
|
public static let engineMode = "config.engineMode"
|
||||||
|
public static let hasCompletedOnboarding = "config.hasCompletedOnboarding"
|
||||||
|
public static let onboardingPage = "config.onboardingPage"
|
||||||
|
public static let hasAcknowledgedCloudSharing = "config.hasAcknowledgedCloudSharing"
|
||||||
|
public static let uiLanguage = "config.uiLanguage"
|
||||||
|
public static let translationTargetLocaleId = "config.translationTargetLocaleId"
|
||||||
|
public static let handednessPreference = "config.handednessPreference"
|
||||||
|
public static let cursorDragNavigationEnabled = "config.cursorDragNavigationEnabled"
|
||||||
|
public static let polishIntensity = "config.polishIntensity"
|
||||||
|
public static let detectedAppContext = "config.detectedAppContext"
|
||||||
|
public static let detectedAppContextAt = "config.detectedAppContextAt"
|
||||||
|
public static let personalDictionary = "config.personalDictionary.v1"
|
||||||
|
/// When true, the host app auto-returns to the source app after a cold-start handoff.
|
||||||
|
public static let flowSkipAppSwitch = "config.flowSkipAppSwitch"
|
||||||
|
/// Raw `FlowInactivityDuration` value; session expires after this idle window.
|
||||||
|
public static let flowInactivityDuration = "config.flowInactivityDuration"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stored fields
|
||||||
|
|
||||||
|
public var providerId: String
|
||||||
|
public var baseURL: String
|
||||||
|
public var model: String
|
||||||
|
public var modeId: String
|
||||||
|
public var localeId: String
|
||||||
|
public var engineMode: String
|
||||||
|
public var hasCompletedOnboarding: Bool
|
||||||
|
public var onboardingPage: Int
|
||||||
|
public var hasAcknowledgedCloudSharing: Bool
|
||||||
|
public var uiLanguage: AppUILanguage
|
||||||
|
public var translationTargetLocaleId: String
|
||||||
|
public var handednessPreference: HandednessPreference
|
||||||
|
public var cursorDragNavigationEnabled: Bool
|
||||||
|
public var polishIntensity: PolishIntensity
|
||||||
|
public var personalDictionary: PersonalDictionary
|
||||||
|
/// Auto-return to the host app after `startflow` cold start (default on).
|
||||||
|
public var flowSkipAppSwitch: Bool
|
||||||
|
/// Idle timeout before the Flow session ends; resets on each utterance.
|
||||||
|
public var flowInactivityDuration: FlowInactivityDuration
|
||||||
|
|
||||||
|
// MARK: - Derived
|
||||||
|
|
||||||
|
/// Translation is on iff a target locale other than `offLocaleId` is selected.
|
||||||
|
public var translationEnabled: Bool {
|
||||||
|
translationTargetLocaleId != TranslationLanguageCatalog.offLocaleId
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isTranslationEffective: Bool {
|
||||||
|
translationEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isLocalEngine: Bool {
|
||||||
|
engineMode == "local"
|
||||||
|
}
|
||||||
|
|
||||||
|
public var polishModeForPipeline: PolishingService.PolishMode {
|
||||||
|
isTranslationEffective
|
||||||
|
? .translate(targetLocaleId: translationTargetLocaleId)
|
||||||
|
: .polish
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local engine pins the LLM step to DeepSeek; cloud uses the user's provider.
|
||||||
|
public var polishProviderIdOverride: String? {
|
||||||
|
engineMode == "local" ? "deepseek" : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isCloudAPIKeyMissingForVoiceInput: Bool {
|
||||||
|
guard engineMode == "cloud" else { return false }
|
||||||
|
return apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
/// API key lives in the Keychain (cross-process, encrypted at rest).
|
||||||
|
public var apiKey: String {
|
||||||
|
Keychain.apiKey(for: providerId) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
public func makeClient() -> LLMClient {
|
||||||
|
OpenAICompatibleClient(
|
||||||
|
baseURL: baseURL,
|
||||||
|
apiKey: apiKey,
|
||||||
|
model: model
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Detected app context
|
||||||
|
|
||||||
|
public func detectedAppContext(from defaults: UserDefaults) -> (context: AppContext, observedAt: Date)? {
|
||||||
|
guard let raw = defaults.string(forKey: Keys.detectedAppContext),
|
||||||
|
let value = AppContext(rawValue: raw)
|
||||||
|
else { return nil }
|
||||||
|
let timestamp = defaults.object(forKey: Keys.detectedAppContextAt) as? Date ?? .distantPast
|
||||||
|
return (value, timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func setDetectedAppContext(_ context: AppContext, at date: Date = Date(), to defaults: UserDefaults) {
|
||||||
|
defaults.set(context.rawValue, forKey: Keys.detectedAppContext)
|
||||||
|
defaults.set(date, forKey: Keys.detectedAppContextAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Load / save
|
||||||
|
|
||||||
|
/// Loads configuration from App Group defaults. Returns `nil` when the suite is unavailable.
|
||||||
|
public static func load(from defaults: UserDefaults? = nil) -> AppGroupConfiguration? {
|
||||||
|
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return nil }
|
||||||
|
return load(fromAvailable: store)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads configuration from a known-available UserDefaults suite.
|
||||||
|
public static func load(fromAvailable defaults: UserDefaults) -> AppGroupConfiguration {
|
||||||
|
var config = AppGroupConfiguration(
|
||||||
|
providerId: defaults.string(forKey: Keys.providerId) ?? "openai",
|
||||||
|
baseURL: "",
|
||||||
|
model: "",
|
||||||
|
modeId: defaults.string(forKey: Keys.modeId) ?? "polish",
|
||||||
|
localeId: defaults.string(forKey: Keys.localeId) ?? "auto",
|
||||||
|
engineMode: defaults.string(forKey: Keys.engineMode) ?? "cloud",
|
||||||
|
hasCompletedOnboarding: defaults.bool(forKey: Keys.hasCompletedOnboarding),
|
||||||
|
onboardingPage: {
|
||||||
|
let saved = defaults.integer(forKey: Keys.onboardingPage)
|
||||||
|
return saved > 0 ? saved : 0
|
||||||
|
}(),
|
||||||
|
hasAcknowledgedCloudSharing: defaults.bool(forKey: Keys.hasAcknowledgedCloudSharing),
|
||||||
|
uiLanguage: AppUILanguage.fromStored(defaults.string(forKey: Keys.uiLanguage)),
|
||||||
|
translationTargetLocaleId: defaults.string(forKey: Keys.translationTargetLocaleId)
|
||||||
|
?? TranslationLanguageCatalog.offLocaleId,
|
||||||
|
handednessPreference: HandednessPreference.fromStored(
|
||||||
|
defaults.string(forKey: Keys.handednessPreference)
|
||||||
|
),
|
||||||
|
cursorDragNavigationEnabled: {
|
||||||
|
if defaults.object(forKey: Keys.cursorDragNavigationEnabled) == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return defaults.bool(forKey: Keys.cursorDragNavigationEnabled)
|
||||||
|
}(),
|
||||||
|
polishIntensity: resolvePolishIntensity(from: defaults),
|
||||||
|
personalDictionary: decodePersonalDictionary(from: defaults),
|
||||||
|
flowSkipAppSwitch: {
|
||||||
|
if defaults.object(forKey: Keys.flowSkipAppSwitch) == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return defaults.bool(forKey: Keys.flowSkipAppSwitch)
|
||||||
|
}(),
|
||||||
|
flowInactivityDuration: FlowInactivityDuration.fromStored(
|
||||||
|
defaults.string(forKey: Keys.flowInactivityDuration)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
let preset = LLMProvider.provider(id: config.providerId)
|
||||||
|
if config.baseURL.isEmpty {
|
||||||
|
config.baseURL = defaults.string(forKey: Keys.baseURL) ?? preset.defaultBaseURL
|
||||||
|
}
|
||||||
|
if config.model.isEmpty {
|
||||||
|
config.model = defaults.string(forKey: Keys.model) ?? preset.defaultModel
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-shot legacy migration: plaintext apiKey in UserDefaults → Keychain.
|
||||||
|
_ = resolveAPIKey(defaults: defaults, providerId: config.providerId)
|
||||||
|
|
||||||
|
// Cloud no longer exposes off/transcribe; migrate legacy values.
|
||||||
|
if config.engineMode == "cloud", config.modeId != "polish" {
|
||||||
|
config.modeId = "polish"
|
||||||
|
defaults.set("polish", forKey: Keys.modeId)
|
||||||
|
}
|
||||||
|
// DeepSeek is local-engine only — never a cloud picker choice.
|
||||||
|
if config.engineMode == "cloud", config.providerId == "deepseek" {
|
||||||
|
let openAI = LLMProvider.provider(id: "openai")
|
||||||
|
config.providerId = openAI.id
|
||||||
|
config.baseURL = openAI.defaultBaseURL
|
||||||
|
config.model = openAI.defaultModel
|
||||||
|
defaults.set(openAI.id, forKey: Keys.providerId)
|
||||||
|
defaults.set(openAI.defaultBaseURL, forKey: Keys.baseURL)
|
||||||
|
defaults.set(openAI.defaultModel, forKey: Keys.model)
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
public func save(to defaults: UserDefaults) {
|
||||||
|
defaults.set(providerId, forKey: Keys.providerId)
|
||||||
|
defaults.set(baseURL, forKey: Keys.baseURL)
|
||||||
|
defaults.set(model, forKey: Keys.model)
|
||||||
|
defaults.set(modeId, forKey: Keys.modeId)
|
||||||
|
defaults.set(localeId, forKey: Keys.localeId)
|
||||||
|
defaults.set(engineMode, forKey: Keys.engineMode)
|
||||||
|
defaults.set(hasCompletedOnboarding, forKey: Keys.hasCompletedOnboarding)
|
||||||
|
defaults.set(onboardingPage, forKey: Keys.onboardingPage)
|
||||||
|
defaults.set(hasAcknowledgedCloudSharing, forKey: Keys.hasAcknowledgedCloudSharing)
|
||||||
|
defaults.set(uiLanguage.rawValue, forKey: Keys.uiLanguage)
|
||||||
|
defaults.set(translationTargetLocaleId, forKey: Keys.translationTargetLocaleId)
|
||||||
|
defaults.set(handednessPreference.rawValue, forKey: Keys.handednessPreference)
|
||||||
|
defaults.set(cursorDragNavigationEnabled, forKey: Keys.cursorDragNavigationEnabled)
|
||||||
|
defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity)
|
||||||
|
defaults.set(flowSkipAppSwitch, forKey: Keys.flowSkipAppSwitch)
|
||||||
|
defaults.set(flowInactivityDuration.rawValue, forKey: Keys.flowInactivityDuration)
|
||||||
|
Self.encodePersonalDictionary(personalDictionary, to: defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private helpers
|
||||||
|
|
||||||
|
private static func resolvePolishIntensity(from defaults: UserDefaults) -> PolishIntensity {
|
||||||
|
guard let raw = defaults.string(forKey: Keys.polishIntensity) else {
|
||||||
|
return .default
|
||||||
|
}
|
||||||
|
let resolved = PolishIntensity.resolve(storedRawValue: raw)
|
||||||
|
if raw == PolishIntensity.legacyOffRawValue {
|
||||||
|
defaults.set(resolved.rawValue, forKey: Keys.polishIntensity)
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func decodePersonalDictionary(from defaults: UserDefaults) -> PersonalDictionary {
|
||||||
|
guard let data = defaults.data(forKey: Keys.personalDictionary) else {
|
||||||
|
return .empty
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
var dictionary = try JSONDecoder().decode(PersonalDictionary.self, from: data)
|
||||||
|
if dictionary.entries.contains(where: { $0.source == .history }) {
|
||||||
|
for index in dictionary.entries.indices where dictionary.entries[index].source == .history {
|
||||||
|
dictionary.entries[index].source = .manual
|
||||||
|
}
|
||||||
|
dictionary.version += 1
|
||||||
|
if let migrated = try? JSONEncoder().encode(dictionary) {
|
||||||
|
defaults.set(migrated, forKey: Keys.personalDictionary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dictionary
|
||||||
|
} catch {
|
||||||
|
OSGLog.config.warning("personalDictionary decode failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
return .empty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func encodePersonalDictionary(_ dictionary: PersonalDictionary, to defaults: UserDefaults) {
|
||||||
|
do {
|
||||||
|
let data = try JSONEncoder().encode(dictionary)
|
||||||
|
defaults.set(data, forKey: Keys.personalDictionary)
|
||||||
|
} catch {
|
||||||
|
OSGLog.config.warning("personalDictionary encode failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the API key from the Keychain, falling back to a one-time migration from UserDefaults.
|
||||||
|
static func resolveAPIKey(defaults: UserDefaults?, providerId: String) -> String {
|
||||||
|
if let stored = Keychain.apiKey(for: providerId), !stored.isEmpty {
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
if let legacyKeychain = Keychain.legacyAPIKey(), !legacyKeychain.isEmpty {
|
||||||
|
try? Keychain.setAPIKey(legacyKeychain, for: providerId)
|
||||||
|
try? Keychain.deleteLegacyAPIKey()
|
||||||
|
return legacyKeychain
|
||||||
|
}
|
||||||
|
if let defaults,
|
||||||
|
let legacy = defaults.string(forKey: Keys.apiKeyLegacy),
|
||||||
|
!legacy.isEmpty {
|
||||||
|
try? Keychain.setAPIKey(legacy, for: providerId)
|
||||||
|
defaults.removeObject(forKey: Keys.apiKeyLegacy)
|
||||||
|
return legacy
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,12 +10,11 @@ public enum EngineServiceLabel {
|
|||||||
engineMode: String,
|
engineMode: String,
|
||||||
providerId: String,
|
providerId: String,
|
||||||
model: String,
|
model: String,
|
||||||
localASRBackend: LocalASRBackend = .speechAnalyzer,
|
|
||||||
language: AppUILanguage? = nil
|
language: AppUILanguage? = nil
|
||||||
) -> String {
|
) -> String {
|
||||||
let lang = language ?? AppGroupStore().uiLanguage
|
let lang = language ?? AppGroupStore().uiLanguage
|
||||||
if engineMode == "local" {
|
if engineMode == "local" {
|
||||||
let asrName = asrDisplayName(for: localASRBackend, language: lang)
|
let asrName = SharedL10n.string("engine.asr.appleSpeech", language: lang)
|
||||||
return SharedL10n.format("engine.summary.local", language: lang, asrName)
|
return SharedL10n.format("engine.summary.local", language: lang, asrName)
|
||||||
}
|
}
|
||||||
let providerName = ProviderDisplayName.name(for: providerId, language: lang)
|
let providerName = ProviderDisplayName.name(for: providerId, language: lang)
|
||||||
@@ -30,14 +29,4 @@ public enum EngineServiceLabel {
|
|||||||
trimmedModel
|
trimmedModel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private static func asrDisplayName(
|
|
||||||
for backend: LocalASRBackend,
|
|
||||||
language: AppUILanguage
|
|
||||||
) -> String {
|
|
||||||
// v0.2.0: only the iOS SpeechAnalyzer path remains. We keep the
|
|
||||||
// switch on `LocalASRBackend` so the next non-iOS backend can
|
|
||||||
// slot in without touching every call site.
|
|
||||||
return SharedL10n.string("engine.asr.appleSpeech", language: language)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// FlowInactivityDuration.swift
|
||||||
|
// OSGKeyboard · Shared
|
||||||
|
//
|
||||||
|
// User-selectable Flow session inactivity timeout. The timer resets after
|
||||||
|
// each completed utterance (and on session start).
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum FlowInactivityDuration: String, CaseIterable, Identifiable, Sendable, Codable {
|
||||||
|
case tenMinutes = "10m"
|
||||||
|
case thirtyMinutes = "30m"
|
||||||
|
case threeHours = "3h"
|
||||||
|
case twelveHours = "12h"
|
||||||
|
case twentyFourHours = "24h"
|
||||||
|
|
||||||
|
public var id: String { rawValue }
|
||||||
|
|
||||||
|
public static let `default`: FlowInactivityDuration = .twelveHours
|
||||||
|
|
||||||
|
public var timeInterval: TimeInterval {
|
||||||
|
switch self {
|
||||||
|
case .tenMinutes: return 10 * 60
|
||||||
|
case .thirtyMinutes: return 30 * 60
|
||||||
|
case .threeHours: return 3 * 60 * 60
|
||||||
|
case .twelveHours: return 12 * 60 * 60
|
||||||
|
case .twentyFourHours: return 24 * 60 * 60
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var labelKey: String {
|
||||||
|
switch self {
|
||||||
|
case .tenMinutes: return "settings.flow.inactivity.10m"
|
||||||
|
case .thirtyMinutes: return "settings.flow.inactivity.30m"
|
||||||
|
case .threeHours: return "settings.flow.inactivity.3h"
|
||||||
|
case .twelveHours: return "settings.flow.inactivity.12h"
|
||||||
|
case .twentyFourHours: return "settings.flow.inactivity.24h"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func fromStored(_ raw: String?) -> FlowInactivityDuration {
|
||||||
|
guard let raw, let value = FlowInactivityDuration(rawValue: raw) else {
|
||||||
|
return .default
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct FlowUtteranceChunkConfig: Sendable, Equatable {
|
public struct FlowUtteranceChunkConfig: Sendable, Equatable {
|
||||||
/// Target maximum duration per ASR chunk.
|
/// Target duration for the first ASR chunk (starts pipelining early).
|
||||||
public let maxChunkDurationSeconds: TimeInterval
|
public let firstChunkDurationSeconds: TimeInterval
|
||||||
|
/// Target duration for later chunks once pipelining is underway.
|
||||||
|
public let subsequentChunkDurationSeconds: TimeInterval
|
||||||
/// Tail overlap fed into the next chunk for boundary dedup when stitching.
|
/// Tail overlap fed into the next chunk for boundary dedup when stitching.
|
||||||
public let overlapDurationSeconds: TimeInterval
|
public let overlapDurationSeconds: TimeInterval
|
||||||
/// After hitting the max window, wait up to this long for a pause before hard-splitting.
|
/// After hitting the max window, wait up to this long for a pause before hard-splitting.
|
||||||
@@ -17,21 +19,52 @@ public struct FlowUtteranceChunkConfig: Sendable, Equatable {
|
|||||||
public let sampleRate: Int
|
public let sampleRate: Int
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
maxChunkDurationSeconds: TimeInterval,
|
firstChunkDurationSeconds: TimeInterval = 2.5,
|
||||||
|
subsequentChunkDurationSeconds: TimeInterval = 5.0,
|
||||||
overlapDurationSeconds: TimeInterval,
|
overlapDurationSeconds: TimeInterval,
|
||||||
pauseExtensionMaxSeconds: TimeInterval,
|
pauseExtensionMaxSeconds: TimeInterval,
|
||||||
pauseRMSThreshold: Float,
|
pauseRMSThreshold: Float,
|
||||||
sampleRate: Int
|
sampleRate: Int
|
||||||
) {
|
) {
|
||||||
self.maxChunkDurationSeconds = maxChunkDurationSeconds
|
self.firstChunkDurationSeconds = firstChunkDurationSeconds
|
||||||
|
self.subsequentChunkDurationSeconds = subsequentChunkDurationSeconds
|
||||||
self.overlapDurationSeconds = overlapDurationSeconds
|
self.overlapDurationSeconds = overlapDurationSeconds
|
||||||
self.pauseExtensionMaxSeconds = pauseExtensionMaxSeconds
|
self.pauseExtensionMaxSeconds = pauseExtensionMaxSeconds
|
||||||
self.pauseRMSThreshold = pauseRMSThreshold
|
self.pauseRMSThreshold = pauseRMSThreshold
|
||||||
self.sampleRate = sampleRate
|
self.sampleRate = sampleRate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Uniform chunk size — used by unit tests and legacy call sites.
|
||||||
|
public init(
|
||||||
|
maxChunkDurationSeconds: TimeInterval,
|
||||||
|
overlapDurationSeconds: TimeInterval,
|
||||||
|
pauseExtensionMaxSeconds: TimeInterval,
|
||||||
|
pauseRMSThreshold: Float,
|
||||||
|
sampleRate: Int
|
||||||
|
) {
|
||||||
|
self.firstChunkDurationSeconds = maxChunkDurationSeconds
|
||||||
|
self.subsequentChunkDurationSeconds = maxChunkDurationSeconds
|
||||||
|
self.overlapDurationSeconds = overlapDurationSeconds
|
||||||
|
self.pauseExtensionMaxSeconds = pauseExtensionMaxSeconds
|
||||||
|
self.pauseRMSThreshold = pauseRMSThreshold
|
||||||
|
self.sampleRate = sampleRate
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Backward-compatible alias for tests that read `maxChunkSamples`.
|
||||||
|
public var maxChunkDurationSeconds: TimeInterval {
|
||||||
|
subsequentChunkDurationSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
public func maxChunkDurationSeconds(forChunkIndex index: Int) -> TimeInterval {
|
||||||
|
index == 0 ? firstChunkDurationSeconds : subsequentChunkDurationSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
public func maxChunkSamples(forChunkIndex index: Int) -> Int {
|
||||||
|
Int(maxChunkDurationSeconds(forChunkIndex: index) * Double(sampleRate))
|
||||||
|
}
|
||||||
|
|
||||||
public var maxChunkSamples: Int {
|
public var maxChunkSamples: Int {
|
||||||
Int(maxChunkDurationSeconds * Double(sampleRate))
|
maxChunkSamples(forChunkIndex: 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
public var overlapSamples: Int {
|
public var overlapSamples: Int {
|
||||||
@@ -44,7 +77,8 @@ public struct FlowUtteranceChunkConfig: Sendable, Equatable {
|
|||||||
|
|
||||||
/// Default for keyboard Flow utterances (≤ 3 min, pipelined ASR).
|
/// Default for keyboard Flow utterances (≤ 3 min, pipelined ASR).
|
||||||
public static let flowDefault = FlowUtteranceChunkConfig(
|
public static let flowDefault = FlowUtteranceChunkConfig(
|
||||||
maxChunkDurationSeconds: 30,
|
firstChunkDurationSeconds: 2.5,
|
||||||
|
subsequentChunkDurationSeconds: 5.0,
|
||||||
overlapDurationSeconds: 0.5,
|
overlapDurationSeconds: 0.5,
|
||||||
pauseExtensionMaxSeconds: 2,
|
pauseExtensionMaxSeconds: 2,
|
||||||
pauseRMSThreshold: 0.015,
|
pauseRMSThreshold: 0.015,
|
||||||
|
|||||||
@@ -82,6 +82,14 @@ 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: "mimo",
|
||||||
|
name: "小米 MiMo",
|
||||||
|
defaultBaseURL: "https://api.xiaomimimo.com/v1",
|
||||||
|
defaultModel: "mimo-v2.5",
|
||||||
|
apiKeyURL: URL(string: "https://platform.xiaomimimo.com"),
|
||||||
|
blurb: "mimo-v2.5 · 中文优化 · Chinese-optimized"
|
||||||
|
),
|
||||||
.init(
|
.init(
|
||||||
id: "custom",
|
id: "custom",
|
||||||
name: "Custom · 自定义",
|
name: "Custom · 自定义",
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
// LocalASRBackend.swift
|
|
||||||
// OSGKeyboard · Shared
|
|
||||||
//
|
|
||||||
// Identifies which on-device speech recognition engine to use when the
|
|
||||||
// user picks the "local" engine (no cloud LLM polish). The shared
|
|
||||||
// factory `ASRServiceFactory` dispatches on this enum; the settings UI
|
|
||||||
// renders it as a picker.
|
|
||||||
//
|
|
||||||
// As of v0.2.0 the only on-device backend is iOS 26 `SpeechAnalyzer`
|
|
||||||
// + `DictationTranscriber`. The previous Qwen3-CoreML backend has
|
|
||||||
// been removed: that path required a ~1.6 GB CoreML bundle, a local
|
|
||||||
// SPM fork that pulled in mlx-swift, and significant app-side state
|
|
||||||
// (download manager, warm-up service, model registry). We now keep the
|
|
||||||
// local engine narrow — same iOS ASR the cloud engine already uses —
|
|
||||||
// and let users opt into a cloud polish step after the transcript is
|
|
||||||
// produced if they need stronger accuracy on noisy audio or dialectal
|
|
||||||
// Chinese. See `LocalPolishConfig` for the post-ASR polish toggle.
|
|
||||||
//
|
|
||||||
// Why an enum in `Shared` rather than a `Bool`: the value must remain
|
|
||||||
// serialisable into the App Group store (so the keyboard extension can
|
|
||||||
// observe the selection) and exposed via `ProviderConfig` (UI binding).
|
|
||||||
// Keeping the type stable even with a single case avoids a migration
|
|
||||||
// the next time someone adds a non-cloud backend (e.g. whisper.cpp).
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public enum LocalASRBackend: String, CaseIterable, Identifiable, Sendable, Codable {
|
|
||||||
/// iOS 26 `SpeechAnalyzer` + `DictationTranscriber`. Always
|
|
||||||
/// on-device, no asset download, ships with iOS. The only local
|
|
||||||
/// backend in v0.2.0.
|
|
||||||
case speechAnalyzer
|
|
||||||
|
|
||||||
public var id: String { rawValue }
|
|
||||||
|
|
||||||
/// Localisation key for the human label in the settings picker.
|
|
||||||
public var labelKey: String {
|
|
||||||
"asr.backend.speechAnalyzer.label"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Localisation key for the one-line subtitle shown under the label.
|
|
||||||
public var blurbKey: String {
|
|
||||||
"asr.backend.speechAnalyzer.blurb"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether this backend needs the user to download a model file
|
|
||||||
/// before it can run. Always `false` for iOS-bundled speech.
|
|
||||||
public var requiresModelDownload: Bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -108,7 +108,9 @@ extension PersonalDictionary.Entry {
|
|||||||
/// learner. Users can re-classify later from Settings.
|
/// learner. Users can re-classify later from Settings.
|
||||||
public static func inferCategory(for term: String) -> Category {
|
public static func inferCategory(for term: String) -> Category {
|
||||||
let hasUpper = term.contains(where: { $0.isUppercase })
|
let hasUpper = term.contains(where: { $0.isUppercase })
|
||||||
let hasDigit = term.contains(where: { $0.isNumber })
|
let hasDigit = term.unicodeScalars.contains { scalar in
|
||||||
|
CharacterSet.decimalDigits.contains(scalar) && scalar.isASCII
|
||||||
|
}
|
||||||
let hasLatin = term.unicodeScalars.contains { scalar in
|
let hasLatin = term.unicodeScalars.contains { scalar in
|
||||||
CharacterSet.letters.contains(scalar) && scalar.isASCII
|
CharacterSet.letters.contains(scalar) && scalar.isASCII
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,144 +15,108 @@ import Combine
|
|||||||
public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||||
public static let shared = ProviderConfig()
|
public static let shared = ProviderConfig()
|
||||||
|
|
||||||
private enum Key {
|
|
||||||
static let providerId = "config.providerId"
|
|
||||||
static let baseURL = "config.baseURL"
|
|
||||||
// Legacy: apiKey used to live in UserDefaults before the
|
|
||||||
// migration. We still read it once (see init below) and then
|
|
||||||
// delete the entry, but no other code path touches this key.
|
|
||||||
static let apiKeyLegacy = "config.apiKey"
|
|
||||||
static let model = "config.model"
|
|
||||||
static let modeId = "config.modeId"
|
|
||||||
static let localeId = "config.localeId"
|
|
||||||
static let engineMode = "config.engineMode"
|
|
||||||
static let hasCompletedOnboarding = "config.hasCompletedOnboarding"
|
|
||||||
static let onboardingPage = "config.onboardingPage"
|
|
||||||
static let hasAcknowledgedCloudSharing = "config.hasAcknowledgedCloudSharing"
|
|
||||||
// Which on-device ASR engine to use when `engineMode == "local"`.
|
|
||||||
// Persisted in the App Group so the keyboard can read the
|
|
||||||
// selection even though it never instantiates the backend itself.
|
|
||||||
static let localASRBackend = "config.localASRBackend"
|
|
||||||
static let uiLanguage = "config.uiLanguage"
|
|
||||||
// v0.2.0: optional cloud polish step after on-device ASR finishes
|
|
||||||
// in the local engine. Default `false` — keeps the local engine
|
|
||||||
// truly local unless the user explicitly opts in.
|
|
||||||
static let localModeCloudPolishEnabled = "config.localModeCloudPolishEnabled"
|
|
||||||
// v0.2.1: optional translation step after ASR. The
|
|
||||||
// post-ASR transcript is routed through the same LLM with a
|
|
||||||
// translate-and-polish prompt targeting `translationTargetLocaleId`.
|
|
||||||
// Mutually exclusive with the local-only promise — see `TranslationPolicy`.
|
|
||||||
//
|
|
||||||
// v0.2.1 follow-up: `config.translationEnabled` was *removed*
|
|
||||||
// as a persisted key — translation is now derived from
|
|
||||||
// `translationTargetLocaleId` (== offLocaleId means "off"). The
|
|
||||||
// store still tolerates legacy reads of the old key so users
|
|
||||||
// who upgraded from a build that wrote it don't see a flash of
|
|
||||||
// "on" state during init, but new writes never touch the key.
|
|
||||||
static let translationTargetLocaleId = "config.translationTargetLocaleId"
|
|
||||||
static let handednessPreference = "config.handednessPreference"
|
|
||||||
static let cursorDragNavigationEnabled = "config.cursorDragNavigationEnabled"
|
|
||||||
// v0.3.0: how aggressively the LLM should rewrite transcripts.
|
|
||||||
static let polishIntensity = "config.polishIntensity"
|
|
||||||
}
|
|
||||||
|
|
||||||
@Published public var providerId: String {
|
@Published public var providerId: String {
|
||||||
didSet {
|
didSet {
|
||||||
defaults.set(providerId, forKey: Key.providerId)
|
guard !isApplyingConfiguration, providerId != configuration.providerId else { return }
|
||||||
// Keep API keys isolated per provider: switching provider in
|
configuration.providerId = providerId
|
||||||
// Settings loads that provider's key instead of reusing the
|
|
||||||
// previously selected vendor's key.
|
|
||||||
isSyncingProviderAPIKey = true
|
isSyncingProviderAPIKey = true
|
||||||
apiKey = Keychain.apiKey(for: providerId) ?? ""
|
apiKey = configuration.apiKey
|
||||||
isSyncingProviderAPIKey = false
|
isSyncingProviderAPIKey = false
|
||||||
|
persistConfiguration()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@Published public var baseURL: String {
|
@Published public var baseURL: String {
|
||||||
didSet { defaults.set(baseURL, forKey: Key.baseURL) }
|
didSet {
|
||||||
|
guard !isApplyingConfiguration, baseURL != configuration.baseURL else { return }
|
||||||
|
configuration.baseURL = baseURL
|
||||||
|
persistConfiguration()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@Published public var apiKey: String {
|
@Published public var apiKey: String {
|
||||||
didSet {
|
didSet {
|
||||||
// Skip the round-trip on init — we read from Keychain and
|
|
||||||
// writing the same value back is wasteful.
|
|
||||||
guard oldValue != apiKey, !isSyncingProviderAPIKey else { return }
|
guard oldValue != apiKey, !isSyncingProviderAPIKey else { return }
|
||||||
do {
|
do {
|
||||||
try Keychain.setAPIKey(apiKey, for: providerId)
|
try Keychain.setAPIKey(apiKey, for: providerId)
|
||||||
} catch {
|
} catch {
|
||||||
#if DEBUG
|
OSGLog.config.warning("Keychain write failed: \(error.localizedDescription, privacy: .public)")
|
||||||
print("⚠️ [OSGKeyboard] Keychain write failed: \(error)")
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@Published public var model: String {
|
@Published public var model: String {
|
||||||
didSet { defaults.set(model, forKey: Key.model) }
|
didSet {
|
||||||
|
guard !isApplyingConfiguration, model != configuration.model else { return }
|
||||||
|
configuration.model = model
|
||||||
|
persistConfiguration()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@Published public var modeId: String {
|
@Published public var modeId: String {
|
||||||
didSet { defaults.set(modeId, forKey: Key.modeId) }
|
didSet {
|
||||||
|
guard !isApplyingConfiguration, modeId != configuration.modeId else { return }
|
||||||
|
configuration.modeId = modeId
|
||||||
|
persistConfiguration()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@Published public var localeId: String {
|
@Published public var localeId: String {
|
||||||
didSet { defaults.set(localeId, forKey: Key.localeId) }
|
didSet {
|
||||||
|
guard !isApplyingConfiguration, localeId != configuration.localeId else { return }
|
||||||
|
configuration.localeId = localeId
|
||||||
|
persistConfiguration()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
/// "local" → on-device ASR + built-in DeepSeek polish.
|
/// "local" → on-device ASR + built-in DeepSeek polish.
|
||||||
/// "cloud" → on-device ASR + user's cloud LLM polish.
|
/// "cloud" → on-device ASR + user's cloud LLM polish.
|
||||||
@Published public var engineMode: String {
|
@Published public var engineMode: String {
|
||||||
didSet {
|
didSet {
|
||||||
defaults.set(engineMode, forKey: Key.engineMode)
|
guard !isApplyingConfiguration, engineMode != configuration.engineMode else { return }
|
||||||
|
configuration.engineMode = engineMode
|
||||||
applyEngineModeSideEffects()
|
applyEngineModeSideEffects()
|
||||||
|
persistConfiguration()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@Published public var hasCompletedOnboarding: Bool {
|
@Published public var hasCompletedOnboarding: Bool {
|
||||||
didSet {
|
didSet {
|
||||||
defaults.set(hasCompletedOnboarding, forKey: Key.hasCompletedOnboarding)
|
guard !isApplyingConfiguration,
|
||||||
|
hasCompletedOnboarding != configuration.hasCompletedOnboarding else { return }
|
||||||
|
configuration.hasCompletedOnboarding = hasCompletedOnboarding
|
||||||
if hasCompletedOnboarding {
|
if hasCompletedOnboarding {
|
||||||
|
configuration.onboardingPage = 0
|
||||||
onboardingPage = 0
|
onboardingPage = 0
|
||||||
}
|
}
|
||||||
|
persistConfiguration()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Persisted onboarding step so returning from Settings does not reset progress.
|
/// Persisted onboarding step so returning from Settings does not reset progress.
|
||||||
@Published public var onboardingPage: Int {
|
@Published public var onboardingPage: Int {
|
||||||
didSet { defaults.set(onboardingPage, forKey: Key.onboardingPage) }
|
didSet {
|
||||||
|
guard !isApplyingConfiguration, onboardingPage != configuration.onboardingPage else { return }
|
||||||
|
configuration.onboardingPage = onboardingPage
|
||||||
|
persistConfiguration()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
/// User confirmed that Cloud polish sends transcripts to their configured third-party API.
|
/// User confirmed that Cloud polish sends transcripts to their configured third-party API.
|
||||||
@Published public var hasAcknowledgedCloudSharing: Bool {
|
@Published public var hasAcknowledgedCloudSharing: Bool {
|
||||||
didSet { defaults.set(hasAcknowledgedCloudSharing, forKey: Key.hasAcknowledgedCloudSharing) }
|
|
||||||
}
|
|
||||||
/// Which on-device ASR engine backs the "local" engine mode. Only
|
|
||||||
/// consulted when `isLocalEngine == true`; the cloud engine always
|
|
||||||
/// uses `SpeechAnalyzer`.
|
|
||||||
@Published public var localASRBackend: LocalASRBackend {
|
|
||||||
didSet { defaults.set(localASRBackend.rawValue, forKey: Key.localASRBackend) }
|
|
||||||
}
|
|
||||||
/// When `engineMode == "local"`, optionally route the ASR transcript
|
|
||||||
/// through the user's configured LLM (DeepSeek by default) before
|
|
||||||
/// inserting at the cursor. The polish step runs through the same
|
|
||||||
/// `LLMClient` + `PolishingService` stack the cloud engine uses.
|
|
||||||
///
|
|
||||||
/// Defaults to `false` — the local engine is ASR-only out of the
|
|
||||||
/// box. Users opt in from Settings when the iOS ASR output isn't
|
|
||||||
/// strong enough (noisy far-field audio, dialectal Chinese, etc.).
|
|
||||||
@Published public var localModeCloudPolishEnabled: Bool {
|
|
||||||
didSet {
|
didSet {
|
||||||
defaults.set(localModeCloudPolishEnabled, forKey: Key.localModeCloudPolishEnabled)
|
guard !isApplyingConfiguration,
|
||||||
AppGroupConfigDarwin.postConfigChanged()
|
hasAcknowledgedCloudSharing != configuration.hasAcknowledgedCloudSharing else { return }
|
||||||
|
configuration.hasAcknowledgedCloudSharing = hasAcknowledgedCloudSharing
|
||||||
|
persistConfiguration()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Host-app UI language. Also mirrored to the App Group for the keyboard extension.
|
/// Host-app UI language. Also mirrored to the App Group for the keyboard extension.
|
||||||
@Published public var uiLanguage: AppUILanguage {
|
@Published public var uiLanguage: AppUILanguage {
|
||||||
didSet { defaults.set(uiLanguage.rawValue, forKey: Key.uiLanguage) }
|
didSet {
|
||||||
|
guard !isApplyingConfiguration, uiLanguage != configuration.uiLanguage else { return }
|
||||||
|
configuration.uiLanguage = uiLanguage
|
||||||
|
persistConfiguration()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
/// v0.2.1: whether to translate the transcript into
|
/// v0.2.1: whether to translate the transcript into
|
||||||
/// `translationTargetLocaleId` before insertion. **Derived** —
|
/// `translationTargetLocaleId` before insertion. **Derived** —
|
||||||
/// translation is on iff the user has selected a target locale
|
/// translation is on iff the user has selected a target locale
|
||||||
/// (i.e. the persisted id is anything other than
|
/// (i.e. the persisted id is anything other than
|
||||||
/// `TranslationLanguageCatalog.offLocaleId`). Default off.
|
/// `TranslationLanguageCatalog.offLocaleId`). Default off.
|
||||||
///
|
|
||||||
/// This used to be a stored `@Published var ... { didSet }` but the
|
|
||||||
/// chip / picker now writes the locale directly; collapsing the
|
|
||||||
/// pair into one field removes the "two writes out of sync" bug
|
|
||||||
/// surface entirely.
|
|
||||||
public var translationEnabled: Bool {
|
public var translationEnabled: Bool {
|
||||||
translationTargetLocaleId != TranslationLanguageCatalog.offLocaleId
|
configuration.translationEnabled
|
||||||
}
|
}
|
||||||
/// v0.2.1: BCP-47-ish target language id (e.g. `en`, `ja`, `ko`) the
|
/// v0.2.1: BCP-47-ish target language id (e.g. `en`, `ja`, `ko`) the
|
||||||
/// translate-and-polish prompt should produce. Default `"off"` —
|
/// translate-and-polish prompt should produce. Default `"off"` —
|
||||||
@@ -161,31 +125,37 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
|||||||
/// the user's choice without a host-app round-trip).
|
/// the user's choice without a host-app round-trip).
|
||||||
@Published public var translationTargetLocaleId: String {
|
@Published public var translationTargetLocaleId: String {
|
||||||
didSet {
|
didSet {
|
||||||
defaults.set(translationTargetLocaleId, forKey: Key.translationTargetLocaleId)
|
guard !isApplyingConfiguration,
|
||||||
AppGroupConfigDarwin.postConfigChanged()
|
translationTargetLocaleId != configuration.translationTargetLocaleId else { return }
|
||||||
|
configuration.translationTargetLocaleId = translationTargetLocaleId
|
||||||
|
persistConfiguration(postConfigChanged: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// 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 / return can swap on the bottom row.
|
||||||
@Published public var handednessPreference: HandednessPreference {
|
@Published public var handednessPreference: HandednessPreference {
|
||||||
didSet {
|
didSet {
|
||||||
defaults.set(handednessPreference.rawValue, forKey: Key.handednessPreference)
|
guard !isApplyingConfiguration,
|
||||||
AppGroupConfigDarwin.postConfigChanged()
|
handednessPreference != configuration.handednessPreference else { return }
|
||||||
|
configuration.handednessPreference = handednessPreference
|
||||||
|
persistConfiguration(postConfigChanged: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 {
|
@Published public var cursorDragNavigationEnabled: Bool {
|
||||||
didSet {
|
didSet {
|
||||||
defaults.set(cursorDragNavigationEnabled, forKey: Key.cursorDragNavigationEnabled)
|
guard !isApplyingConfiguration,
|
||||||
AppGroupConfigDarwin.postConfigChanged()
|
cursorDragNavigationEnabled != configuration.cursorDragNavigationEnabled else { return }
|
||||||
|
configuration.cursorDragNavigationEnabled = cursorDragNavigationEnabled
|
||||||
|
persistConfiguration(postConfigChanged: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the pipeline should run translate-and-polish (not just
|
/// Whether the pipeline should run translate-and-polish (not just
|
||||||
/// polish). Both engines honour the selected target locale.
|
/// polish). Both engines honour the selected target locale.
|
||||||
public var isTranslationEffective: Bool {
|
public var isTranslationEffective: Bool {
|
||||||
translationEnabled
|
configuration.isTranslationEffective
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Translation picker visibility — available on both engines.
|
/// Translation picker visibility — available on both engines.
|
||||||
@@ -194,21 +164,41 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
|||||||
/// v0.3.0: how aggressively the LLM should rewrite the ASR
|
/// v0.3.0: how aggressively the LLM should rewrite the ASR
|
||||||
/// transcript. Default is `medium` (Typeless-equivalent).
|
/// transcript. Default is `medium` (Typeless-equivalent).
|
||||||
@Published public var polishIntensity: PolishIntensity {
|
@Published public var polishIntensity: PolishIntensity {
|
||||||
didSet { defaults.set(polishIntensity.rawValue, forKey: Key.polishIntensity) }
|
didSet {
|
||||||
|
guard !isApplyingConfiguration, polishIntensity != configuration.polishIntensity else { return }
|
||||||
|
configuration.polishIntensity = polishIntensity
|
||||||
|
persistConfiguration()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When enabled, the host app tries to return to the source app after a cold-start handoff.
|
||||||
|
@Published public var flowSkipAppSwitch: Bool {
|
||||||
|
didSet {
|
||||||
|
guard !isApplyingConfiguration, flowSkipAppSwitch != configuration.flowSkipAppSwitch else { return }
|
||||||
|
configuration.flowSkipAppSwitch = flowSkipAppSwitch
|
||||||
|
persistConfiguration()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Idle window before an active Flow session expires; resets on each utterance.
|
||||||
|
@Published public var flowInactivityDuration: FlowInactivityDuration {
|
||||||
|
didSet {
|
||||||
|
guard !isApplyingConfiguration,
|
||||||
|
flowInactivityDuration != configuration.flowInactivityDuration else { return }
|
||||||
|
configuration.flowInactivityDuration = flowInactivityDuration
|
||||||
|
persistConfiguration()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var isConfigured: Bool {
|
public var isConfigured: Bool {
|
||||||
// Local engine (on-device ASR only) doesn't need an API key,
|
// Local engine uses on-device ASR + built-in DeepSeek polish and
|
||||||
// base URL, or model — the LLM round-trip is skipped entirely.
|
// does not need a user API key. Cloud needs base URL, key, and model.
|
||||||
// Treat it as always-configured so onboarding's "Next" button
|
|
||||||
// enables the moment the user picks the local path, instead
|
|
||||||
// of forcing them to fill in cloud fields they won't use.
|
|
||||||
if isLocalEngine { return true }
|
if isLocalEngine { return true }
|
||||||
return !baseURL.isEmpty && !apiKey.isEmpty && !model.isEmpty
|
return !baseURL.isEmpty && !apiKey.isEmpty && !model.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
/// On-device ASR only; no cloud API required.
|
/// On-device ASR only; no cloud API required.
|
||||||
public var isLocalEngine: Bool { engineMode == "local" }
|
public var isLocalEngine: Bool { configuration.isLocalEngine }
|
||||||
|
|
||||||
/// Local engine always polishes via the built-in DeepSeek path.
|
/// Local engine always polishes via the built-in DeepSeek path.
|
||||||
public var shouldPolishLocalTranscript: Bool { isLocalEngine }
|
public var shouldPolishLocalTranscript: Bool { isLocalEngine }
|
||||||
@@ -217,83 +207,39 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
|||||||
public var localModeProviderId: String { "deepseek" }
|
public var localModeProviderId: String { "deepseek" }
|
||||||
|
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
|
private var configuration: AppGroupConfiguration
|
||||||
|
private var isApplyingConfiguration = false
|
||||||
private var isSyncingProviderAPIKey = false
|
private var isSyncingProviderAPIKey = false
|
||||||
|
|
||||||
public init(defaults: UserDefaults? = nil) {
|
public init(defaults: UserDefaults? = nil) {
|
||||||
let resolvedDefaults: UserDefaults = defaults ?? (AppGroup.isAvailable ? AppGroup.defaults : .standard)
|
guard let resolvedDefaults = defaults ?? AppGroup.defaultsIfAvailable else {
|
||||||
|
preconditionFailure(
|
||||||
|
"ProviderConfig requires App Group or injected UserDefaults — " +
|
||||||
|
"check AppGroup.isAvailable before constructing."
|
||||||
|
)
|
||||||
|
}
|
||||||
self.defaults = resolvedDefaults
|
self.defaults = resolvedDefaults
|
||||||
let pid = resolvedDefaults.string(forKey: Key.providerId) ?? "openai"
|
self.configuration = AppGroupConfiguration.load(fromAvailable: resolvedDefaults)
|
||||||
let preset = LLMProvider.provider(id: pid)
|
|
||||||
self.providerId = pid
|
|
||||||
self.baseURL = resolvedDefaults.string(forKey: Key.baseURL) ?? preset.defaultBaseURL
|
|
||||||
|
|
||||||
// Resolve the API key with a one-shot migration from the legacy
|
isApplyingConfiguration = true
|
||||||
// UserDefaults slot. After this runs once, `Key.apiKeyLegacy`
|
providerId = configuration.providerId
|
||||||
// is empty in the suite and all subsequent reads go through the
|
baseURL = configuration.baseURL
|
||||||
// Keychain.
|
apiKey = configuration.apiKey
|
||||||
self.apiKey = ProviderConfig.resolveAPIKey(defaults: resolvedDefaults, providerId: pid)
|
model = configuration.model
|
||||||
|
modeId = configuration.modeId
|
||||||
self.model = resolvedDefaults.string(forKey: Key.model) ?? preset.defaultModel
|
localeId = configuration.localeId
|
||||||
self.modeId = resolvedDefaults.string(forKey: Key.modeId) ?? "polish"
|
engineMode = configuration.engineMode
|
||||||
self.localeId = resolvedDefaults.string(forKey: Key.localeId) ?? "auto"
|
hasCompletedOnboarding = configuration.hasCompletedOnboarding
|
||||||
self.engineMode = resolvedDefaults.string(forKey: Key.engineMode) ?? "cloud"
|
onboardingPage = configuration.onboardingPage
|
||||||
self.hasCompletedOnboarding = resolvedDefaults.bool(forKey: Key.hasCompletedOnboarding)
|
hasAcknowledgedCloudSharing = configuration.hasAcknowledgedCloudSharing
|
||||||
let savedPage = resolvedDefaults.integer(forKey: Key.onboardingPage)
|
uiLanguage = configuration.uiLanguage
|
||||||
self.onboardingPage = savedPage > 0 ? savedPage : 0
|
translationTargetLocaleId = configuration.translationTargetLocaleId
|
||||||
self.hasAcknowledgedCloudSharing = resolvedDefaults.bool(forKey: Key.hasAcknowledgedCloudSharing)
|
handednessPreference = configuration.handednessPreference
|
||||||
// Tolerate missing / unknown raw values (e.g. an enum case that
|
cursorDragNavigationEnabled = configuration.cursorDragNavigationEnabled
|
||||||
// was renamed in a later build) by falling back to the default
|
polishIntensity = configuration.polishIntensity
|
||||||
// rather than crashing inside `RawRepresentable.init`.
|
flowSkipAppSwitch = configuration.flowSkipAppSwitch
|
||||||
let rawBackend = resolvedDefaults.string(forKey: Key.localASRBackend) ?? LocalASRBackend.speechAnalyzer.rawValue
|
flowInactivityDuration = configuration.flowInactivityDuration
|
||||||
self.localASRBackend = LocalASRBackend(rawValue: rawBackend) ?? .speechAnalyzer
|
isApplyingConfiguration = false
|
||||||
// v0.2.0: local-mode cloud polish toggle. Defaults off; users
|
|
||||||
// opt in from Settings when iOS ASR is too lossy for their
|
|
||||||
// environment. `object(forKey:) == nil` covers fresh installs
|
|
||||||
// and upgrades from builds that never wrote the key.
|
|
||||||
if resolvedDefaults.object(forKey: Key.localModeCloudPolishEnabled) == nil {
|
|
||||||
self.localModeCloudPolishEnabled = false
|
|
||||||
} else {
|
|
||||||
self.localModeCloudPolishEnabled = resolvedDefaults.bool(forKey: Key.localModeCloudPolishEnabled)
|
|
||||||
}
|
|
||||||
self.uiLanguage = AppUILanguage.fromStored(
|
|
||||||
resolvedDefaults.string(forKey: Key.uiLanguage)
|
|
||||||
)
|
|
||||||
// v0.2.1 follow-up: `translationEnabled` is now derived from
|
|
||||||
// `translationTargetLocaleId` — no separate init read.
|
|
||||||
// Default the locale id to `offLocaleId` so existing installs
|
|
||||||
// that never picked a target language stay in the "off" state
|
|
||||||
// (the previous build's default of `"en"` would silently turn
|
|
||||||
// translation on for every upgraded user; off is the safe
|
|
||||||
// conservative default that matches the picker / chip UX).
|
|
||||||
self.translationTargetLocaleId = resolvedDefaults.string(forKey: Key.translationTargetLocaleId)
|
|
||||||
?? TranslationLanguageCatalog.offLocaleId
|
|
||||||
self.handednessPreference = HandednessPreference.fromStored(
|
|
||||||
resolvedDefaults.string(forKey: Key.handednessPreference)
|
|
||||||
)
|
|
||||||
if resolvedDefaults.object(forKey: Key.cursorDragNavigationEnabled) == nil {
|
|
||||||
self.cursorDragNavigationEnabled = true
|
|
||||||
} else {
|
|
||||||
self.cursorDragNavigationEnabled = resolvedDefaults.bool(forKey: Key.cursorDragNavigationEnabled)
|
|
||||||
}
|
|
||||||
// v0.3.0: polish intensity. Default to `.medium` for new
|
|
||||||
// installs; legacy `"off"` migrates to `.medium`.
|
|
||||||
if let raw = resolvedDefaults.string(forKey: Key.polishIntensity) {
|
|
||||||
self.polishIntensity = PolishIntensity.resolve(storedRawValue: raw)
|
|
||||||
if raw == PolishIntensity.legacyOffRawValue {
|
|
||||||
resolvedDefaults.set(PolishIntensity.medium.rawValue, forKey: Key.polishIntensity)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.polishIntensity = .default
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cloud no longer exposes off/transcribe; migrate legacy values.
|
|
||||||
if self.engineMode == "cloud", self.modeId != "polish" {
|
|
||||||
self.modeId = "polish"
|
|
||||||
}
|
|
||||||
// DeepSeek is local-engine only — never a cloud picker choice.
|
|
||||||
if self.engineMode == "cloud", self.providerId == "deepseek" {
|
|
||||||
apply(preset: LLMProvider.provider(id: "openai"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Keep cloud vs local provider choices isolated when the user
|
/// Keep cloud vs local provider choices isolated when the user
|
||||||
@@ -304,29 +250,15 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the API key from the Keychain, falling back to a one-time
|
private func persistConfiguration(postConfigChanged: Bool = false) {
|
||||||
/// migration from the legacy UserDefaults slot.
|
configuration.save(to: defaults)
|
||||||
private static func resolveAPIKey(defaults: UserDefaults, providerId: String) -> String {
|
if postConfigChanged {
|
||||||
if let stored = Keychain.apiKey(for: providerId), !stored.isEmpty {
|
AppGroupConfigDarwin.postConfigChanged()
|
||||||
return stored
|
|
||||||
}
|
}
|
||||||
// Migration path: old builds stored one global key under
|
|
||||||
// Keychain account "current". Move it to the active provider.
|
|
||||||
if let legacyKeychain = Keychain.legacyAPIKey(), !legacyKeychain.isEmpty {
|
|
||||||
try? Keychain.setAPIKey(legacyKeychain, for: providerId)
|
|
||||||
try? Keychain.deleteLegacyAPIKey()
|
|
||||||
return legacyKeychain
|
|
||||||
}
|
|
||||||
if let legacy = defaults.string(forKey: Key.apiKeyLegacy),
|
|
||||||
!legacy.isEmpty {
|
|
||||||
try? Keychain.setAPIKey(legacy, for: providerId)
|
|
||||||
defaults.removeObject(forKey: Key.apiKeyLegacy)
|
|
||||||
return legacy
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func apply(preset: LLMProvider) {
|
public func apply(preset: LLMProvider) {
|
||||||
|
isApplyingConfiguration = true
|
||||||
providerId = preset.id
|
providerId = preset.id
|
||||||
if !preset.defaultBaseURL.isEmpty {
|
if !preset.defaultBaseURL.isEmpty {
|
||||||
baseURL = preset.defaultBaseURL
|
baseURL = preset.defaultBaseURL
|
||||||
@@ -334,15 +266,31 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
|||||||
if !preset.defaultModel.isEmpty {
|
if !preset.defaultModel.isEmpty {
|
||||||
model = preset.defaultModel
|
model = preset.defaultModel
|
||||||
}
|
}
|
||||||
|
configuration.providerId = providerId
|
||||||
|
configuration.baseURL = baseURL
|
||||||
|
configuration.model = model
|
||||||
|
isSyncingProviderAPIKey = true
|
||||||
|
apiKey = configuration.apiKey
|
||||||
|
isSyncingProviderAPIKey = false
|
||||||
|
isApplyingConfiguration = false
|
||||||
|
persistConfiguration()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func reset() {
|
public func reset() {
|
||||||
providerId = "openai"
|
isApplyingConfiguration = true
|
||||||
let preset = LLMProvider.provider(id: "openai")
|
let preset = LLMProvider.provider(id: "openai")
|
||||||
|
providerId = preset.id
|
||||||
baseURL = preset.defaultBaseURL
|
baseURL = preset.defaultBaseURL
|
||||||
apiKey = ""
|
apiKey = ""
|
||||||
model = preset.defaultModel
|
model = preset.defaultModel
|
||||||
handednessPreference = .left
|
handednessPreference = .left
|
||||||
hasAcknowledgedCloudSharing = false
|
hasAcknowledgedCloudSharing = false
|
||||||
|
configuration.providerId = preset.id
|
||||||
|
configuration.baseURL = preset.defaultBaseURL
|
||||||
|
configuration.model = preset.defaultModel
|
||||||
|
configuration.handednessPreference = .left
|
||||||
|
configuration.hasAcknowledgedCloudSharing = false
|
||||||
|
isApplyingConfiguration = false
|
||||||
|
persistConfiguration()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"bin_bytes" : 174285,
|
||||||
|
"bin_file" : "OSGKeyboardCLM.bin",
|
||||||
|
"export_seconds" : 0.029847979545593262,
|
||||||
|
"generated_at" : "2026-07-05T11:34:09Z",
|
||||||
|
"identifier" : "com.osgkeyboard.custom-lm.v1",
|
||||||
|
"locale" : "zh_CN",
|
||||||
|
"phrase_count" : 11550,
|
||||||
|
"sources" : {
|
||||||
|
"ai_tech_seed" : 1259,
|
||||||
|
"computer_terms" : 10300
|
||||||
|
},
|
||||||
|
"version" : "1.0.0"
|
||||||
|
}
|
||||||
@@ -47,6 +47,9 @@ public protocol ASRService: Sendable {
|
|||||||
/// Clears cancellation / cached session state before a new utterance.
|
/// Clears cancellation / cached session state before a new utterance.
|
||||||
func resetForNewUtterance()
|
func resetForNewUtterance()
|
||||||
|
|
||||||
|
/// Pre-load locale assets and analyzer format for lower first-chunk latency.
|
||||||
|
func warmup(locale: Locale) async
|
||||||
|
|
||||||
/// Transcribe one PCM chunk (Flow pipelined path). Default wraps `transcribe(stream:)`.
|
/// Transcribe one PCM chunk (Flow pipelined path). Default wraps `transcribe(stream:)`.
|
||||||
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult
|
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult
|
||||||
}
|
}
|
||||||
@@ -60,6 +63,8 @@ public enum ASRChunkResult: Sendable, Equatable {
|
|||||||
extension ASRService {
|
extension ASRService {
|
||||||
public func resetForNewUtterance() {}
|
public func resetForNewUtterance() {}
|
||||||
|
|
||||||
|
public func warmup(locale: Locale) async {}
|
||||||
|
|
||||||
public func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
|
public func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
|
||||||
guard !samples.isEmpty else { return .success("") }
|
guard !samples.isEmpty else { return .success("") }
|
||||||
if Task.isCancelled { return .cancelled }
|
if Task.isCancelled { return .cancelled }
|
||||||
@@ -116,27 +121,7 @@ public enum ASREvent: Sendable, Equatable {
|
|||||||
// MARK: - Factory
|
// MARK: - Factory
|
||||||
|
|
||||||
public enum ASRServiceFactory {
|
public enum ASRServiceFactory {
|
||||||
/// Returns the on-device ASR backend. As of v0.2.0 the only
|
/// Returns the on-device `SpeechAnalyzer` + `DictationTranscriber` backend.
|
||||||
/// supported `LocalASRBackend` is iOS 26 `SpeechAnalyzer` +
|
|
||||||
/// `DictationTranscriber` (always on-device, no asset download),
|
|
||||||
/// so the factory collapses to a single concrete type. We keep the
|
|
||||||
/// `localBackend` parameter on the signature so the next non-iOS
|
|
||||||
/// backend can slot in without touching every call site.
|
|
||||||
///
|
|
||||||
/// The cloud engine also routes through `SpeechAnalyzerASR`: the
|
|
||||||
/// user expectation is that ASR is the local half of the pipeline
|
|
||||||
/// regardless of where the LLM polish happens.
|
|
||||||
public static func make(
|
|
||||||
engineMode: String,
|
|
||||||
localBackend: LocalASRBackend = .speechAnalyzer
|
|
||||||
) -> ASRService {
|
|
||||||
SpeechAnalyzerASR()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Back-compat overload for callers that only ever want the
|
|
||||||
/// SpeechAnalyzer path. The previous single-backend build used
|
|
||||||
/// this signature; new code should pass the engine mode explicitly
|
|
||||||
/// so any future non-iOS backend is honoured.
|
|
||||||
public static func make() -> ASRService {
|
public static func make() -> ASRService {
|
||||||
SpeechAnalyzerASR()
|
SpeechAnalyzerASR()
|
||||||
}
|
}
|
||||||
@@ -197,12 +182,52 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable {
|
|||||||
private var chunkAnalyzerFormat: AVAudioFormat?
|
private var chunkAnalyzerFormat: AVAudioFormat?
|
||||||
|
|
||||||
func resetForNewUtterance() {
|
func resetForNewUtterance() {
|
||||||
|
// Keep chunk format / asset cache warm across utterances in one Flow session.
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidateChunkPreparationCache() {
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
chunkPreparedLocaleID = nil
|
chunkPreparedLocaleID = nil
|
||||||
chunkAnalyzerFormat = nil
|
chunkAnalyzerFormat = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func warmup(locale: Locale) async {
|
||||||
|
guard let resolvedLocale = await DictationTranscriber.supportedLocale(equivalentTo: locale) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let localeID = resolvedLocale.identifier(.bcp47)
|
||||||
|
let cachedLocaleID = lock.withLock { chunkPreparedLocaleID }
|
||||||
|
if cachedLocaleID == localeID, lock.withLock({ chunkAnalyzerFormat != nil }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let lmConfiguration = CustomLanguageModelManager.shared.configurationForTranscription(
|
||||||
|
locale: resolvedLocale
|
||||||
|
)
|
||||||
|
let transcriber = CustomLanguageModelManager.makeDictationTranscriber(
|
||||||
|
locale: resolvedLocale,
|
||||||
|
lmConfiguration: lmConfiguration
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await Self.prepareAssetsIfNeeded(for: transcriber, locale: resolvedLocale)
|
||||||
|
guard let format = await SpeechAnalyzer.bestAvailableAudioFormat(
|
||||||
|
compatibleWith: [transcriber],
|
||||||
|
considering: Self.captureFormat
|
||||||
|
) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lock.withLock {
|
||||||
|
chunkPreparedLocaleID = localeID
|
||||||
|
chunkAnalyzerFormat = format
|
||||||
|
}
|
||||||
|
Self.debug("warmup ready locale=\(localeID)")
|
||||||
|
} catch {
|
||||||
|
Self.debug("warmup failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
|
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
|
||||||
guard !samples.isEmpty else { return .success("") }
|
guard !samples.isEmpty else { return .success("") }
|
||||||
if Task.isCancelled { return .cancelled }
|
if Task.isCancelled { return .cancelled }
|
||||||
@@ -228,9 +253,12 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable {
|
|||||||
throw ASRChunkError.localeUnsupported
|
throw ASRChunkError.localeUnsupported
|
||||||
}
|
}
|
||||||
let localeID = resolvedLocale.identifier(.bcp47)
|
let localeID = resolvedLocale.identifier(.bcp47)
|
||||||
let transcriber = DictationTranscriber(
|
let lmConfiguration = CustomLanguageModelManager.shared.configurationForTranscription(
|
||||||
|
locale: resolvedLocale
|
||||||
|
)
|
||||||
|
let transcriber = CustomLanguageModelManager.makeDictationTranscriber(
|
||||||
locale: resolvedLocale,
|
locale: resolvedLocale,
|
||||||
preset: .progressiveLongDictation
|
lmConfiguration: lmConfiguration
|
||||||
)
|
)
|
||||||
|
|
||||||
let analyzerFormat: AVAudioFormat
|
let analyzerFormat: AVAudioFormat
|
||||||
@@ -337,9 +365,12 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
// Each pipelined chunk is ≤ 30 s; long dictation preset keeps a
|
// Each pipelined chunk is ≤ 30 s; long dictation preset keeps a
|
||||||
// single chunk coherent (Flow utterances run up to 3 min).
|
// single chunk coherent (Flow utterances run up to 3 min).
|
||||||
let transcriber = DictationTranscriber(
|
let lmConfiguration = CustomLanguageModelManager.shared.configurationForTranscription(
|
||||||
|
locale: resolvedLocale
|
||||||
|
)
|
||||||
|
let transcriber = CustomLanguageModelManager.makeDictationTranscriber(
|
||||||
locale: resolvedLocale,
|
locale: resolvedLocale,
|
||||||
preset: .progressiveLongDictation
|
lmConfiguration: lmConfiguration
|
||||||
)
|
)
|
||||||
do {
|
do {
|
||||||
try await Self.prepareAssetsIfNeeded(for: transcriber, locale: resolvedLocale)
|
try await Self.prepareAssetsIfNeeded(for: transcriber, locale: resolvedLocale)
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
// AppGroupStore.swift
|
// AppGroupStore.swift
|
||||||
// OSGKeyboard · Shared
|
// OSGKeyboard · Shared
|
||||||
//
|
//
|
||||||
// Convenience wrapper around App Group UserDefaults for non-Published reads.
|
// Thin read/write facade over `AppGroupConfiguration` for the keyboard
|
||||||
// Used by the keyboard extension (no SwiftUI) to read config without
|
// extension (no SwiftUI) and other non-ObservableObject call sites.
|
||||||
// instantiating an ObservableObject.
|
|
||||||
//
|
//
|
||||||
// `apiKey` is NOT read from UserDefaults — see `Keychain.swift`. We
|
// `apiKey` is NOT stored in UserDefaults — see `Keychain.swift`.
|
||||||
// share access between the host app and the keyboard extension via a
|
|
||||||
// shared keychain-access-group declared in both targets' entitlements.
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@@ -19,340 +16,150 @@ public struct AppGroupStore: @unchecked Sendable {
|
|||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Never hard-crash on implicit construction sites (e.g. default
|
guard let available = AppGroup.defaultsIfAvailable else {
|
||||||
// service initializers). If App Group is unavailable, use .standard
|
#if DEBUG
|
||||||
// so callers can still surface a user-facing setup error.
|
fatalError("App Group unavailable — inject UserDefaults in tests or fix entitlements.")
|
||||||
self.defaults = AppGroup.isAvailable ? AppGroup.defaults : .standard
|
#else
|
||||||
|
// Callers must check `AppGroup.isAvailable` before constructing.
|
||||||
|
fatalError("App Group unavailable.")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
self.defaults = available
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Keys
|
private var configuration: AppGroupConfiguration {
|
||||||
|
AppGroupConfiguration.load(fromAvailable: defaults)
|
||||||
|
}
|
||||||
|
|
||||||
private enum Key {
|
private func mutateConfiguration(_ transform: (inout AppGroupConfiguration) -> Void) {
|
||||||
static let providerId = "config.providerId"
|
var config = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||||
static let baseURL = "config.baseURL"
|
transform(&config)
|
||||||
static let model = "config.model"
|
config.save(to: defaults)
|
||||||
static let modeId = "config.modeId"
|
|
||||||
static let localeId = "config.localeId"
|
|
||||||
static let engineMode = "config.engineMode"
|
|
||||||
static let localASRBackend = "config.localASRBackend"
|
|
||||||
static let uiLanguage = "config.uiLanguage"
|
|
||||||
// v0.2.0: opt-in cloud polish step after local-mode ASR.
|
|
||||||
static let localModeCloudPolishEnabled = "config.localModeCloudPolishEnabled"
|
|
||||||
// v0.2.1 follow-up: `config.translationEnabled` was *removed* as a
|
|
||||||
// persisted key — translation is derived from the target locale
|
|
||||||
// id. New code should only write/read `translationTargetLocaleId`;
|
|
||||||
// the `translationEnabled` Bool accessor below is kept as a
|
|
||||||
// computed shim for source compatibility.
|
|
||||||
static let translationTargetLocaleId = "config.translationTargetLocaleId"
|
|
||||||
static let handednessPreference = "config.handednessPreference"
|
|
||||||
// Drag pads beside the mic move the caret like arrow keys.
|
|
||||||
static let cursorDragNavigationEnabled = "config.cursorDragNavigationEnabled"
|
|
||||||
// v0.3.0: polish intensity (off / light / medium / heavy).
|
|
||||||
static let polishIntensity = "config.polishIntensity"
|
|
||||||
// v0.3.0: last app context detected by the keyboard extension.
|
|
||||||
// Reused across calls within a 30-minute window so the LLM
|
|
||||||
// prompt remains consistent during a single typing session.
|
|
||||||
static let detectedAppContext = "config.detectedAppContext"
|
|
||||||
static let detectedAppContextAt = "config.detectedAppContextAt"
|
|
||||||
// v0.3.0: personal dictionary — JSON-encoded `PersonalDictionary`.
|
|
||||||
static let personalDictionary = "config.personalDictionary.v1"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Reads
|
// MARK: - Reads
|
||||||
|
|
||||||
public var providerId: String {
|
public var providerId: String { configuration.providerId }
|
||||||
defaults.string(forKey: Key.providerId) ?? "openai"
|
public var baseURL: String { configuration.baseURL }
|
||||||
}
|
public var apiKey: String { configuration.apiKey }
|
||||||
|
public var model: String { configuration.model }
|
||||||
public var baseURL: String {
|
public var modeId: String { configuration.modeId }
|
||||||
defaults.string(forKey: Key.baseURL) ?? LLMProvider.provider(id: providerId).defaultBaseURL
|
public var localeId: String { configuration.localeId }
|
||||||
}
|
public var engineMode: String { configuration.engineMode }
|
||||||
|
public var uiLanguage: AppUILanguage { configuration.uiLanguage }
|
||||||
/// API key lives in the Keychain (cross-process, encrypted at rest).
|
public var translationEnabled: Bool { configuration.translationEnabled }
|
||||||
/// Returns "" when nothing is stored so the LLMClient can surface a
|
public var translationTargetLocaleId: String { configuration.translationTargetLocaleId }
|
||||||
/// `noAPIKey` error rather than firing off an obviously-bad request.
|
public var handednessPreference: HandednessPreference { configuration.handednessPreference }
|
||||||
public var apiKey: String {
|
public var cursorDragNavigationEnabled: Bool { configuration.cursorDragNavigationEnabled }
|
||||||
Keychain.apiKey(for: providerId) ?? ""
|
public var polishIntensity: PolishIntensity { configuration.polishIntensity }
|
||||||
}
|
public var isTranslationEffective: Bool { configuration.isTranslationEffective }
|
||||||
|
public var isLocalEngine: Bool { configuration.isLocalEngine }
|
||||||
public var model: String {
|
public var polishModeForPipeline: PolishingService.PolishMode { configuration.polishModeForPipeline }
|
||||||
defaults.string(forKey: Key.model) ?? LLMProvider.provider(id: providerId).defaultModel
|
public var polishProviderIdOverride: String? { configuration.polishProviderIdOverride }
|
||||||
}
|
public var isCloudAPIKeyMissingForVoiceInput: Bool { configuration.isCloudAPIKeyMissingForVoiceInput }
|
||||||
|
|
||||||
public var modeId: String {
|
|
||||||
defaults.string(forKey: Key.modeId) ?? "polish"
|
|
||||||
}
|
|
||||||
|
|
||||||
public var localeId: String {
|
|
||||||
defaults.string(forKey: Key.localeId) ?? "auto"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// "local" → on-device ASR only (raw transcript delivery).
|
|
||||||
/// "cloud" → ASR + LLM polish (default behaviour).
|
|
||||||
public var engineMode: String {
|
|
||||||
defaults.string(forKey: Key.engineMode) ?? "cloud"
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Which on-device ASR engine backs the "local" engine mode. Falls
|
|
||||||
/// back to the iOS SpeechAnalyzer path so legacy installs (which
|
|
||||||
/// never wrote this key) keep working.
|
|
||||||
public var localASRBackend: LocalASRBackend {
|
|
||||||
let raw = defaults.string(forKey: Key.localASRBackend) ?? LocalASRBackend.speechAnalyzer.rawValue
|
|
||||||
return LocalASRBackend(rawValue: raw) ?? .speechAnalyzer
|
|
||||||
}
|
|
||||||
|
|
||||||
/// v0.2.0: whether the local engine should route its transcript
|
|
||||||
/// through the configured cloud LLM (DeepSeek by default) before
|
|
||||||
/// insertion. Defaults to `false`; the keyboard extension reads
|
|
||||||
/// this so Flow sessions honour the toggle.
|
|
||||||
public var localModeCloudPolishEnabled: Bool {
|
|
||||||
guard defaults.object(forKey: Key.localModeCloudPolishEnabled) != nil else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return defaults.bool(forKey: Key.localModeCloudPolishEnabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Host-app UI language override (`auto` / `en` / `zh-Hans`).
|
|
||||||
public var uiLanguage: AppUILanguage {
|
|
||||||
AppUILanguage.fromStored(defaults.string(forKey: Key.uiLanguage))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// v0.2.1 follow-up: derived — translation is on iff a target locale
|
|
||||||
/// has been selected. The `translationTargetLocaleId` getter below
|
|
||||||
/// is the source of truth; this property exists for backwards
|
|
||||||
/// compatibility with call sites that read `store.translationEnabled`.
|
|
||||||
public var translationEnabled: Bool {
|
|
||||||
translationTargetLocaleId != TranslationLanguageCatalog.offLocaleId
|
|
||||||
}
|
|
||||||
|
|
||||||
/// v0.2.1: target locale id the translate-and-polish prompt should
|
|
||||||
/// produce (e.g. `"en"`, `"ja"`). Defaults to `offLocaleId` ("off")
|
|
||||||
/// when nothing is stored, matching the picker / chip UX where the
|
|
||||||
/// user has to actively pick a language to turn translation on.
|
|
||||||
public var translationTargetLocaleId: String {
|
|
||||||
defaults.string(forKey: Key.translationTargetLocaleId)
|
|
||||||
?? TranslationLanguageCatalog.offLocaleId
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bottom-row key order on the keyboard extension.
|
|
||||||
public var handednessPreference: HandednessPreference {
|
|
||||||
HandednessPreference.fromStored(defaults.string(forKey: Key.handednessPreference))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Press-and-drag pads beside the mic for four-way caret movement.
|
|
||||||
/// Defaults to `true` for new installs.
|
|
||||||
public var cursorDragNavigationEnabled: Bool {
|
|
||||||
guard defaults.object(forKey: Key.cursorDragNavigationEnabled) != nil else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return defaults.bool(forKey: Key.cursorDragNavigationEnabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Writes
|
|
||||||
|
|
||||||
public func setModeId(_ id: String) {
|
|
||||||
defaults.set(id, forKey: Key.modeId)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func setLocaleId(_ id: String) {
|
|
||||||
defaults.set(id, forKey: Key.localeId)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func setEngineMode(_ mode: String) {
|
|
||||||
defaults.set(mode, forKey: Key.engineMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func setLocalASRBackend(_ backend: LocalASRBackend) {
|
|
||||||
defaults.set(backend.rawValue, forKey: Key.localASRBackend)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func setUILanguage(_ language: AppUILanguage) {
|
|
||||||
defaults.set(language.rawValue, forKey: Key.uiLanguage)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// v0.2.1 follow-up: kept for source compatibility with callers that
|
|
||||||
/// still pass a Bool (e.g. older tests, any leftover bridge code).
|
|
||||||
/// `enabled == true` selects `defaultLocaleId` ("en") as a sensible
|
|
||||||
/// on-ramp target; `enabled == false` resets to `offLocaleId`.
|
|
||||||
/// The keyboard chip / pipeline now write the locale id directly
|
|
||||||
/// via `setTranslationTargetLocaleId`, which is the preferred path.
|
|
||||||
public func setTranslationEnabled(_ enabled: Bool) {
|
|
||||||
defaults.set(
|
|
||||||
enabled ? TranslationLanguageCatalog.defaultLocaleId : TranslationLanguageCatalog.offLocaleId,
|
|
||||||
forKey: Key.translationTargetLocaleId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// v0.2.1: persist target locale id (e.g. `"en"`, `"ja"`, or
|
|
||||||
/// `TranslationLanguageCatalog.offLocaleId`). The keyboard
|
|
||||||
/// extension reads this on every `load()` and `refreshRuntimeFlags()`
|
|
||||||
/// so the chip reflects the latest value without a host-app
|
|
||||||
/// round-trip.
|
|
||||||
public func setTranslationTargetLocaleId(_ id: String) {
|
|
||||||
defaults.set(id, forKey: Key.translationTargetLocaleId)
|
|
||||||
AppGroupConfigDarwin.postConfigChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func setHandednessPreference(_ preference: HandednessPreference) {
|
|
||||||
defaults.set(preference.rawValue, forKey: Key.handednessPreference)
|
|
||||||
AppGroupConfigDarwin.postConfigChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
public func setCursorDragNavigationEnabled(_ enabled: Bool) {
|
|
||||||
defaults.set(enabled, forKey: Key.cursorDragNavigationEnabled)
|
|
||||||
AppGroupConfigDarwin.postConfigChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether ASR output should be sent through the LLM polish step.
|
|
||||||
/// Both engines always run polish after ASR completes (chunked
|
|
||||||
/// pipeline stitches first). Ultra-short structure-free utterances
|
|
||||||
/// may skip the LLM inside `PolishingService`.
|
|
||||||
public var shouldRunCloudLLMStep: Bool { true }
|
|
||||||
|
|
||||||
/// Whether translate-and-polish should run (vs polish-only).
|
|
||||||
public var isTranslationEffective: Bool {
|
|
||||||
translationEnabled
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the keyboard top-bar translation chip should render.
|
/// Whether the keyboard top-bar translation chip should render.
|
||||||
public var isTranslationChipVisible: Bool { true }
|
public var isTranslationChipVisible: Bool { true }
|
||||||
|
|
||||||
/// Cloud engine requires a provider-specific API key before the user
|
// MARK: - Writes
|
||||||
/// can start voice input. Local engine uses the built-in DeepSeek path.
|
|
||||||
public var isCloudAPIKeyMissingForVoiceInput: Bool {
|
public func setModeId(_ id: String) {
|
||||||
guard engineMode == "cloud" else { return false }
|
mutateConfiguration { $0.modeId = id }
|
||||||
return apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Polish vs translate-and-polish for the active pipeline.
|
public func setLocaleId(_ id: String) {
|
||||||
public var polishModeForPipeline: PolishingService.PolishMode {
|
mutateConfiguration { $0.localeId = id }
|
||||||
isTranslationEffective
|
|
||||||
? .translate(targetLocaleId: translationTargetLocaleId)
|
|
||||||
: .polish
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Local engine pins the LLM step to DeepSeek; cloud uses the
|
public func setEngineMode(_ mode: String) {
|
||||||
/// user's configured provider.
|
mutateConfiguration { config in
|
||||||
public var polishProviderIdOverride: String? {
|
config.engineMode = mode
|
||||||
engineMode == "local" ? "deepseek" : nil
|
if mode == "cloud", config.providerId == "deepseek" {
|
||||||
}
|
let openAI = LLMProvider.provider(id: "openai")
|
||||||
|
config.providerId = openAI.id
|
||||||
// MARK: - Polish settings (v0.3.0+)
|
config.baseURL = openAI.defaultBaseURL
|
||||||
|
config.model = openAI.defaultModel
|
||||||
/// How aggressively the LLM should rewrite the ASR transcript.
|
}
|
||||||
/// Defaults to `medium` for new installs.
|
|
||||||
public var polishIntensity: PolishIntensity {
|
|
||||||
guard let raw = defaults.string(forKey: Key.polishIntensity) else {
|
|
||||||
return .default
|
|
||||||
}
|
}
|
||||||
let resolved = PolishIntensity.resolve(storedRawValue: raw)
|
}
|
||||||
if raw == PolishIntensity.legacyOffRawValue {
|
|
||||||
defaults.set(resolved.rawValue, forKey: Key.polishIntensity)
|
public func setUILanguage(_ language: AppUILanguage) {
|
||||||
}
|
mutateConfiguration { $0.uiLanguage = language }
|
||||||
return resolved
|
}
|
||||||
|
|
||||||
|
public func setTranslationEnabled(_ enabled: Bool) {
|
||||||
|
setTranslationTargetLocaleId(
|
||||||
|
enabled ? TranslationLanguageCatalog.defaultLocaleId : TranslationLanguageCatalog.offLocaleId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setTranslationTargetLocaleId(_ id: String) {
|
||||||
|
mutateConfiguration { $0.translationTargetLocaleId = id }
|
||||||
|
AppGroupConfigDarwin.postConfigChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setHandednessPreference(_ preference: HandednessPreference) {
|
||||||
|
mutateConfiguration { $0.handednessPreference = preference }
|
||||||
|
AppGroupConfigDarwin.postConfigChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setCursorDragNavigationEnabled(_ enabled: Bool) {
|
||||||
|
mutateConfiguration { $0.cursorDragNavigationEnabled = enabled }
|
||||||
|
AppGroupConfigDarwin.postConfigChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
public func setPolishIntensity(_ intensity: PolishIntensity) {
|
public func setPolishIntensity(_ intensity: PolishIntensity) {
|
||||||
defaults.set(intensity.rawValue, forKey: Key.polishIntensity)
|
mutateConfiguration { $0.polishIntensity = intensity }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Onboarding (v0.3.0+)
|
|
||||||
//
|
|
||||||
// Mirrored from `ProviderConfig` so the keyboard extension's
|
|
||||||
// overlay can read / write the same source of truth without
|
|
||||||
// instantiating the main-app config (which would drag in
|
|
||||||
// SwiftUI / Combine and fight the keyboard's main-thread budget).
|
|
||||||
|
|
||||||
public var hasCompletedOnboarding: Bool {
|
public var hasCompletedOnboarding: Bool {
|
||||||
get { defaults.bool(forKey: "config.hasCompletedOnboarding") }
|
get { configuration.hasCompletedOnboarding }
|
||||||
set { defaults.set(newValue, forKey: "config.hasCompletedOnboarding") }
|
set { setHasCompletedOnboarding(newValue) }
|
||||||
}
|
}
|
||||||
|
|
||||||
public var onboardingPage: Int {
|
public var onboardingPage: Int {
|
||||||
get { defaults.integer(forKey: "config.onboardingPage") }
|
get { configuration.onboardingPage }
|
||||||
set { defaults.set(newValue, forKey: "config.onboardingPage") }
|
set { setOnboardingPage(newValue) }
|
||||||
}
|
}
|
||||||
|
|
||||||
public func setHasCompletedOnboarding(_ completed: Bool) {
|
public func setHasCompletedOnboarding(_ completed: Bool) {
|
||||||
defaults.set(completed, forKey: "config.hasCompletedOnboarding")
|
mutateConfiguration { config in
|
||||||
|
config.hasCompletedOnboarding = completed
|
||||||
|
if completed {
|
||||||
|
config.onboardingPage = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func setOnboardingPage(_ page: Int) {
|
public func setOnboardingPage(_ page: Int) {
|
||||||
defaults.set(page, forKey: "config.onboardingPage")
|
mutateConfiguration { $0.onboardingPage = page }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Detected app context (v0.3.0+)
|
// MARK: - Detected app context
|
||||||
|
|
||||||
/// Last app context the keyboard extension detected for this
|
|
||||||
/// user, plus the timestamp it was observed. Callers should
|
|
||||||
/// treat values older than 30 minutes as stale.
|
|
||||||
public var detectedAppContext: (context: AppContext, observedAt: Date)? {
|
public var detectedAppContext: (context: AppContext, observedAt: Date)? {
|
||||||
guard let raw = defaults.string(forKey: Key.detectedAppContext),
|
configuration.detectedAppContext(from: defaults)
|
||||||
let value = AppContext(rawValue: raw)
|
|
||||||
else { return nil }
|
|
||||||
let timestamp = defaults.object(forKey: Key.detectedAppContextAt) as? Date ?? .distantPast
|
|
||||||
return (value, timestamp)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func setDetectedAppContext(_ context: AppContext, at date: Date = Date()) {
|
public func setDetectedAppContext(_ context: AppContext, at date: Date = Date()) {
|
||||||
defaults.set(context.rawValue, forKey: Key.detectedAppContext)
|
var config = configuration
|
||||||
defaults.set(date, forKey: Key.detectedAppContextAt)
|
config.setDetectedAppContext(context, at: date, to: defaults)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Personal dictionary (v0.3.0+)
|
// MARK: - Personal dictionary
|
||||||
|
|
||||||
/// Personal dictionary persisted in the App Group so both the
|
|
||||||
/// main app's Settings UI and the keyboard extension's LLM call
|
|
||||||
/// read the same source of truth. Returns an empty dictionary
|
|
||||||
/// when nothing is stored (and when the stored JSON is corrupt —
|
|
||||||
/// failing closed is safer than crashing the keyboard).
|
|
||||||
public var personalDictionary: PersonalDictionary {
|
public var personalDictionary: PersonalDictionary {
|
||||||
get {
|
get { configuration.personalDictionary }
|
||||||
guard let data = defaults.data(forKey: Key.personalDictionary) else {
|
set { setPersonalDictionary(newValue) }
|
||||||
return .empty
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
var dictionary = try JSONDecoder().decode(PersonalDictionary.self, from: data)
|
|
||||||
if dictionary.entries.contains(where: { $0.source == .history }) {
|
|
||||||
for index in dictionary.entries.indices where dictionary.entries[index].source == .history {
|
|
||||||
dictionary.entries[index].source = .manual
|
|
||||||
}
|
|
||||||
dictionary.version += 1
|
|
||||||
if let migrated = try? JSONEncoder().encode(dictionary) {
|
|
||||||
defaults.set(migrated, forKey: Key.personalDictionary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return dictionary
|
|
||||||
} catch {
|
|
||||||
#if DEBUG
|
|
||||||
print("⚠️ [AppGroupStore] personalDictionary decode failed: \(error)")
|
|
||||||
#endif
|
|
||||||
return .empty
|
|
||||||
}
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
setPersonalDictionary(newValue)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func setPersonalDictionary(_ dictionary: PersonalDictionary) {
|
public func setPersonalDictionary(_ dictionary: PersonalDictionary) {
|
||||||
do {
|
mutateConfiguration { $0.personalDictionary = dictionary }
|
||||||
let data = try JSONEncoder().encode(dictionary)
|
|
||||||
defaults.set(data, forKey: Key.personalDictionary)
|
|
||||||
} catch {
|
|
||||||
#if DEBUG
|
|
||||||
print("⚠️ [AppGroupStore] personalDictionary encode failed: \(error)")
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Client
|
// MARK: - Client
|
||||||
|
|
||||||
public func makeClient() -> LLMClient {
|
public func makeClient() -> LLMClient {
|
||||||
OpenAICompatibleClient(
|
configuration.makeClient()
|
||||||
baseURL: baseURL,
|
|
||||||
apiKey: apiKey,
|
|
||||||
model: model
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
// CustomLanguageModelManager.swift
|
||||||
|
// OSGKeyboard · Shared
|
||||||
|
//
|
||||||
|
// Prepares the bundled SFCustomLanguageModelData asset on device and shares
|
||||||
|
// the compiled LM + Vocab through the App Group container. Both the host app
|
||||||
|
// and keyboard extension read the same prepared configuration for
|
||||||
|
// DictationTranscriber content hints.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Speech
|
||||||
|
import os
|
||||||
|
|
||||||
|
public final class CustomLanguageModelManager: @unchecked Sendable {
|
||||||
|
|
||||||
|
public static let shared = CustomLanguageModelManager()
|
||||||
|
|
||||||
|
public enum PrepareState: Equatable, Sendable {
|
||||||
|
case idle
|
||||||
|
case preparing
|
||||||
|
case ready
|
||||||
|
case failed(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BundledManifest: Decodable {
|
||||||
|
let version: String
|
||||||
|
let bin_bytes: Int
|
||||||
|
let identifier: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Storage {
|
||||||
|
static let subdirectory = "CustomLanguageModel/v1"
|
||||||
|
static let fingerprintKey = "customLM.preparedFingerprint"
|
||||||
|
static let preparedAtKey = "customLM.preparedAt"
|
||||||
|
static let lastFailureAtKey = "customLM.lastFailureAt"
|
||||||
|
static let attemptCountKey = "customLM.attemptCount"
|
||||||
|
static let maxRetryAttempts = 3
|
||||||
|
/// Backoff after failure attempts 1, 2, and 3 (seconds).
|
||||||
|
static let backoffIntervals: [TimeInterval] = [30, 120, 600]
|
||||||
|
}
|
||||||
|
|
||||||
|
private let lock = OSAllocatedUnfairLock()
|
||||||
|
private var cachedConfiguration: SFSpeechLanguageModel.Configuration?
|
||||||
|
private var state: PrepareState = .idle
|
||||||
|
private var prepareTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
/// Returns a prepared configuration for Chinese locales when available.
|
||||||
|
public func configurationForTranscription(locale: Locale) -> SFSpeechLanguageModel.Configuration? {
|
||||||
|
guard Self.isChineseLocale(locale) else { return nil }
|
||||||
|
return lock.withLock { () -> SFSpeechLanguageModel.Configuration? in
|
||||||
|
if let cachedConfiguration {
|
||||||
|
return cachedConfiguration
|
||||||
|
}
|
||||||
|
if let loaded = Self.loadCachedConfigurationFromDisk() {
|
||||||
|
cachedConfiguration = loaded
|
||||||
|
state = .ready
|
||||||
|
return loaded
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func currentState() -> PrepareState {
|
||||||
|
lock.withLock { state }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fire-and-forget preparation for the host app. Safe to call repeatedly.
|
||||||
|
/// Retries after exponential backoff when a prior attempt failed.
|
||||||
|
public func prepareInBackgroundIfNeeded() {
|
||||||
|
guard AppGroup.isAvailable else { return }
|
||||||
|
|
||||||
|
let shouldStart = lock.withLock { () -> Bool in
|
||||||
|
if case .preparing = state { return false }
|
||||||
|
if cachedConfiguration != nil { return false }
|
||||||
|
if let loaded = Self.loadCachedConfigurationFromDisk() {
|
||||||
|
cachedConfiguration = loaded
|
||||||
|
state = .ready
|
||||||
|
Self.clearRetryState()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if prepareTask != nil { return false }
|
||||||
|
|
||||||
|
if case .failed = state {
|
||||||
|
guard Self.canRetryAfterFailure() else { return false }
|
||||||
|
} else if !Self.canRetryAfterFailure() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
state = .preparing
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
guard shouldStart else { return }
|
||||||
|
|
||||||
|
prepareTask = Task.detached(priority: .utility) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
defer {
|
||||||
|
self.lock.withLock { self.prepareTask = nil }
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
_ = try await self.prepareIfNeeded()
|
||||||
|
} catch {
|
||||||
|
Self.recordFailure()
|
||||||
|
self.lock.withLock {
|
||||||
|
self.state = .failed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
Self.log(
|
||||||
|
"prepare failed (attempt \(Self.storedAttemptCount())): \(error.localizedDescription)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prepares the bundled training asset into the App Group container.
|
||||||
|
@discardableResult
|
||||||
|
public func prepareIfNeeded() async throws -> SFSpeechLanguageModel.Configuration? {
|
||||||
|
if let existing = configurationForTranscription(locale: Locale(identifier: "zh-Hans")) {
|
||||||
|
lock.withLock { state = .ready }
|
||||||
|
Self.clearRetryState()
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
guard Self.canRetryAfterFailure() else {
|
||||||
|
throw PrepareError.retryBudgetExhausted
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let manifest = Self.bundledManifest() else {
|
||||||
|
throw PrepareError.missingManifest
|
||||||
|
}
|
||||||
|
guard let assetURL = Self.bundledTrainingAssetURL() else {
|
||||||
|
throw PrepareError.missingTrainingAsset
|
||||||
|
}
|
||||||
|
guard let preparedDir = Self.preparedDirectoryURL() else {
|
||||||
|
throw PrepareError.missingAppGroupContainer
|
||||||
|
}
|
||||||
|
|
||||||
|
let fingerprint = Self.fingerprint(for: manifest)
|
||||||
|
if Self.storedFingerprint() == fingerprint,
|
||||||
|
let cached = Self.loadCachedConfigurationFromDisk() {
|
||||||
|
lock.withLock {
|
||||||
|
cachedConfiguration = cached
|
||||||
|
state = .ready
|
||||||
|
}
|
||||||
|
Self.clearRetryState()
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
lock.withLock { state = .preparing }
|
||||||
|
|
||||||
|
let languageModelURL = preparedDir.appendingPathComponent("LM")
|
||||||
|
let vocabularyURL = preparedDir.appendingPathComponent("Vocab")
|
||||||
|
try Self.removeItemIfExists(at: languageModelURL)
|
||||||
|
try Self.removeItemIfExists(at: vocabularyURL)
|
||||||
|
|
||||||
|
let configuration = SFSpeechLanguageModel.Configuration(
|
||||||
|
languageModel: languageModelURL,
|
||||||
|
vocabulary: vocabularyURL
|
||||||
|
)
|
||||||
|
|
||||||
|
Self.log("preparing custom LM (\(manifest.bin_bytes) byte asset)…")
|
||||||
|
try await Self.prepareLanguageModel(assetURL: assetURL, configuration: configuration)
|
||||||
|
|
||||||
|
guard FileManager.default.fileExists(atPath: languageModelURL.path),
|
||||||
|
FileManager.default.fileExists(atPath: vocabularyURL.path) else {
|
||||||
|
throw PrepareError.missingPreparedArtifacts
|
||||||
|
}
|
||||||
|
|
||||||
|
AppGroup.defaultsIfAvailable?.set(fingerprint, forKey: Storage.fingerprintKey)
|
||||||
|
AppGroup.defaultsIfAvailable?.set(Date().timeIntervalSince1970, forKey: Storage.preparedAtKey)
|
||||||
|
Self.clearRetryState()
|
||||||
|
|
||||||
|
lock.withLock {
|
||||||
|
cachedConfiguration = configuration
|
||||||
|
state = .ready
|
||||||
|
}
|
||||||
|
|
||||||
|
Self.log("custom LM ready at \(preparedDir.path)")
|
||||||
|
return configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - DictationTranscriber factory
|
||||||
|
|
||||||
|
public static func makeDictationTranscriber(
|
||||||
|
locale: Locale,
|
||||||
|
lmConfiguration: SFSpeechLanguageModel.Configuration?
|
||||||
|
) -> DictationTranscriber {
|
||||||
|
let preset = DictationTranscriber.Preset.progressiveLongDictation
|
||||||
|
guard let lmConfiguration, isChineseLocale(locale) else {
|
||||||
|
return DictationTranscriber(locale: locale, preset: preset)
|
||||||
|
}
|
||||||
|
|
||||||
|
let contentHints = preset.contentHints.union([
|
||||||
|
.customizedLanguage(modelConfiguration: lmConfiguration),
|
||||||
|
])
|
||||||
|
return DictationTranscriber(
|
||||||
|
locale: locale,
|
||||||
|
contentHints: contentHints,
|
||||||
|
transcriptionOptions: preset.transcriptionOptions,
|
||||||
|
reportingOptions: preset.reportingOptions,
|
||||||
|
attributeOptions: preset.attributeOptions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bundle / disk helpers
|
||||||
|
|
||||||
|
private static var resourceBundle: Bundle {
|
||||||
|
Bundle(for: CustomLanguageModelManager.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func bundledTrainingAssetURL() -> URL? {
|
||||||
|
if let url = resourceBundle.url(
|
||||||
|
forResource: "OSGKeyboardCLM",
|
||||||
|
withExtension: "bin",
|
||||||
|
subdirectory: Storage.subdirectory
|
||||||
|
) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
return resourceBundle.url(forResource: "OSGKeyboardCLM", withExtension: "bin")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func bundledManifest() -> BundledManifest? {
|
||||||
|
let manifestURL =
|
||||||
|
resourceBundle.url(
|
||||||
|
forResource: "compiled-manifest",
|
||||||
|
withExtension: "json",
|
||||||
|
subdirectory: Storage.subdirectory
|
||||||
|
)
|
||||||
|
?? resourceBundle.url(forResource: "compiled-manifest", withExtension: "json")
|
||||||
|
guard let manifestURL,
|
||||||
|
let data = try? Data(contentsOf: manifestURL),
|
||||||
|
let manifest = try? JSONDecoder().decode(BundledManifest.self, from: data)
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return manifest
|
||||||
|
}
|
||||||
|
|
||||||
|
static func preparedDirectoryURL() -> URL? {
|
||||||
|
guard let container = FileManager.default.containerURL(
|
||||||
|
forSecurityApplicationGroupIdentifier: AppGroup.identifier
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let directory = container.appendingPathComponent(Storage.subdirectory, isDirectory: true)
|
||||||
|
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
return directory
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadCachedConfigurationFromDisk() -> SFSpeechLanguageModel.Configuration? {
|
||||||
|
guard let manifest = bundledManifest(),
|
||||||
|
storedFingerprint() == fingerprint(for: manifest),
|
||||||
|
let preparedDir = preparedDirectoryURL()
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let languageModelURL = preparedDir.appendingPathComponent("LM")
|
||||||
|
let vocabularyURL = preparedDir.appendingPathComponent("Vocab")
|
||||||
|
let fm = FileManager.default
|
||||||
|
guard fm.fileExists(atPath: languageModelURL.path),
|
||||||
|
fm.fileExists(atPath: vocabularyURL.path) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return SFSpeechLanguageModel.Configuration(
|
||||||
|
languageModel: languageModelURL,
|
||||||
|
vocabulary: vocabularyURL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isChineseLocale(_ locale: Locale) -> Bool {
|
||||||
|
locale.identifier(.bcp47).lowercased().hasPrefix("zh")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fingerprint(for manifest: BundledManifest) -> String {
|
||||||
|
"\(manifest.identifier)|\(manifest.version)|\(manifest.bin_bytes)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func storedFingerprint() -> String? {
|
||||||
|
AppGroup.defaultsIfAvailable?.string(forKey: Storage.fingerprintKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func removeItemIfExists(at url: URL) throws {
|
||||||
|
let fm = FileManager.default
|
||||||
|
if fm.fileExists(atPath: url.path) {
|
||||||
|
try fm.removeItem(at: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func prepareLanguageModel(
|
||||||
|
assetURL: URL,
|
||||||
|
configuration: SFSpeechLanguageModel.Configuration
|
||||||
|
) async throws {
|
||||||
|
try await withCheckedThrowingContinuation {
|
||||||
|
(continuation: CheckedContinuation<Void, Error>) in
|
||||||
|
SFSpeechLanguageModel.prepareCustomLanguageModel(
|
||||||
|
for: assetURL,
|
||||||
|
configuration: configuration
|
||||||
|
) { error in
|
||||||
|
if let error {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
} else {
|
||||||
|
continuation.resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Retry / backoff
|
||||||
|
|
||||||
|
private static func storedAttemptCount() -> Int {
|
||||||
|
AppGroup.defaultsIfAvailable?.integer(forKey: Storage.attemptCountKey) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func storedLastFailureAt() -> TimeInterval? {
|
||||||
|
let value = AppGroup.defaultsIfAvailable?.double(forKey: Storage.lastFailureAtKey) ?? 0
|
||||||
|
return value > 0 ? value : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func recordFailure() {
|
||||||
|
guard let defaults = AppGroup.defaultsIfAvailable else { return }
|
||||||
|
let nextAttempt = storedAttemptCount() + 1
|
||||||
|
defaults.set(nextAttempt, forKey: Storage.attemptCountKey)
|
||||||
|
defaults.set(Date().timeIntervalSince1970, forKey: Storage.lastFailureAtKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func clearRetryState() {
|
||||||
|
guard let defaults = AppGroup.defaultsIfAvailable else { return }
|
||||||
|
defaults.removeObject(forKey: Storage.attemptCountKey)
|
||||||
|
defaults.removeObject(forKey: Storage.lastFailureAtKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns false when retry budget is exhausted or backoff has not elapsed.
|
||||||
|
private static func canRetryAfterFailure() -> Bool {
|
||||||
|
let attempts = storedAttemptCount()
|
||||||
|
guard attempts > 0 else { return true }
|
||||||
|
guard attempts <= Storage.maxRetryAttempts else { return false }
|
||||||
|
|
||||||
|
guard let lastFailureAt = storedLastFailureAt() else { return true }
|
||||||
|
let backoffIndex = min(attempts - 1, Storage.backoffIntervals.count - 1)
|
||||||
|
let requiredDelay = Storage.backoffIntervals[backoffIndex]
|
||||||
|
let elapsed = Date().timeIntervalSince1970 - lastFailureAt
|
||||||
|
return elapsed >= requiredDelay
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func log(_ message: String) {
|
||||||
|
OSGLog.clm.info("\(message, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PrepareError: LocalizedError {
|
||||||
|
case missingManifest
|
||||||
|
case missingTrainingAsset
|
||||||
|
case missingAppGroupContainer
|
||||||
|
case missingPreparedArtifacts
|
||||||
|
case retryBudgetExhausted
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .missingManifest:
|
||||||
|
return "Missing bundled custom language model manifest."
|
||||||
|
case .missingTrainingAsset:
|
||||||
|
return "Missing bundled custom language model training asset."
|
||||||
|
case .missingAppGroupContainer:
|
||||||
|
return "App Group container unavailable for custom language model preparation."
|
||||||
|
case .missingPreparedArtifacts:
|
||||||
|
return "Custom language model preparation did not produce LM/Vocab artifacts."
|
||||||
|
case .retryBudgetExhausted:
|
||||||
|
return "Custom language model preparation retry budget exhausted."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
// DictationBridge.swift
|
|
||||||
// OSGKeyboard · Shared
|
|
||||||
//
|
|
||||||
// Lightweight App Group bridge for host-app dictation handoff:
|
|
||||||
// keyboard extension -> open host app for recording
|
|
||||||
// host app -> writes final transcript
|
|
||||||
// keyboard extension -> consumes pending transcript and inserts text
|
|
||||||
//
|
|
||||||
// STATUS (v0.1.2): Retained. Consumed by `KeyboardViewController` for
|
|
||||||
// the "one-shot" host-app dictation path (where the keyboard extension
|
|
||||||
// launches the host app, the user records there, and the resulting
|
|
||||||
// text is consumed back by the extension). The *continuous* path goes
|
|
||||||
// through `FlowSessionBridge` + `FlowSessionManager` instead.
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public enum DictationBridge {
|
|
||||||
public enum Status: String, Sendable, Equatable {
|
|
||||||
case idle
|
|
||||||
case requested
|
|
||||||
case recording
|
|
||||||
case transcribing
|
|
||||||
case done
|
|
||||||
case cancelled
|
|
||||||
case error
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum Key {
|
|
||||||
static let pendingText = "dictation.pendingText"
|
|
||||||
static let polishWarning = "dictation.polishWarning"
|
|
||||||
static let updatedAt = "dictation.updatedAt"
|
|
||||||
static let status = "dictation.status"
|
|
||||||
static let statusUpdatedAt = "dictation.statusUpdatedAt"
|
|
||||||
static let statusMessage = "dictation.statusMessage"
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func resolvedDefaults(_ defaults: UserDefaults?) -> UserDefaults {
|
|
||||||
if let defaults {
|
|
||||||
return defaults
|
|
||||||
}
|
|
||||||
return AppGroup.isAvailable ? AppGroup.defaults : .standard
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func setStatus(
|
|
||||||
_ status: Status,
|
|
||||||
message: String? = nil,
|
|
||||||
defaults: UserDefaults? = nil
|
|
||||||
) {
|
|
||||||
let store = resolvedDefaults(defaults)
|
|
||||||
store.set(status.rawValue, forKey: Key.status)
|
|
||||||
store.set(Date().timeIntervalSince1970, forKey: Key.statusUpdatedAt)
|
|
||||||
if let message, !message.isEmpty {
|
|
||||||
store.set(message, forKey: Key.statusMessage)
|
|
||||||
} else {
|
|
||||||
store.removeObject(forKey: Key.statusMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func currentStatus(
|
|
||||||
defaults: UserDefaults? = nil
|
|
||||||
) -> (status: Status, message: String?, updatedAt: TimeInterval) {
|
|
||||||
let store = resolvedDefaults(defaults)
|
|
||||||
let raw = store.string(forKey: Key.status) ?? Status.idle.rawValue
|
|
||||||
let status = Status(rawValue: raw) ?? .idle
|
|
||||||
let message = store.string(forKey: Key.statusMessage)
|
|
||||||
let updatedAt = store.double(forKey: Key.statusUpdatedAt)
|
|
||||||
return (status, message, updatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func markRequested(defaults: UserDefaults? = nil) {
|
|
||||||
let store = resolvedDefaults(defaults)
|
|
||||||
store.removeObject(forKey: Key.pendingText)
|
|
||||||
setStatus(.requested, defaults: store)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Store a transcript for the keyboard extension to consume.
|
|
||||||
public static func storePendingTranscript(
|
|
||||||
_ text: String,
|
|
||||||
polishWarning: String? = nil,
|
|
||||||
defaults: UserDefaults? = nil
|
|
||||||
) {
|
|
||||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !trimmed.isEmpty else { return }
|
|
||||||
let store = resolvedDefaults(defaults)
|
|
||||||
store.set(trimmed, forKey: Key.pendingText)
|
|
||||||
store.set(Date().timeIntervalSince1970, forKey: Key.updatedAt)
|
|
||||||
if let polishWarning, !polishWarning.isEmpty {
|
|
||||||
store.set(polishWarning, forKey: Key.polishWarning)
|
|
||||||
} else {
|
|
||||||
store.removeObject(forKey: Key.polishWarning)
|
|
||||||
}
|
|
||||||
setStatus(.done, defaults: store)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns and clears the pending transcript if present.
|
|
||||||
public static func consumePendingTranscript(
|
|
||||||
maxAge: TimeInterval = 180,
|
|
||||||
defaults: UserDefaults? = nil
|
|
||||||
) -> String? {
|
|
||||||
consumePendingDelivery(maxAge: maxAge, defaults: defaults)?.text
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns and clears the pending delivery (text + optional polish
|
|
||||||
/// warning) if present.
|
|
||||||
public static func consumePendingDelivery(
|
|
||||||
maxAge: TimeInterval = 180,
|
|
||||||
defaults: UserDefaults? = nil
|
|
||||||
) -> TranscriptionDelivery? {
|
|
||||||
let store = resolvedDefaults(defaults)
|
|
||||||
guard let text = store.string(forKey: Key.pendingText) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if maxAge > 0 {
|
|
||||||
let ts = store.double(forKey: Key.updatedAt)
|
|
||||||
if ts > 0, Date().timeIntervalSince1970 - ts > maxAge {
|
|
||||||
clear(defaults: store)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let warning = store.string(forKey: Key.polishWarning)
|
|
||||||
store.removeObject(forKey: Key.pendingText)
|
|
||||||
store.removeObject(forKey: Key.polishWarning)
|
|
||||||
store.removeObject(forKey: Key.updatedAt)
|
|
||||||
setStatus(.idle, defaults: store)
|
|
||||||
return TranscriptionDelivery(text: text, polishWarning: warning)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func clear(defaults: UserDefaults? = nil) {
|
|
||||||
let store = resolvedDefaults(defaults)
|
|
||||||
store.removeObject(forKey: Key.pendingText)
|
|
||||||
store.removeObject(forKey: Key.polishWarning)
|
|
||||||
store.removeObject(forKey: Key.updatedAt)
|
|
||||||
setStatus(.idle, defaults: store)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
// FlowAppLifecycle.swift
|
|
||||||
// OSGKeyboard · Shared
|
|
||||||
//
|
|
||||||
// Tracks whether the host app process is in the foreground.
|
|
||||||
// Retained for any future GPU-backed paths; CoreML ASR does not require it.
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
public final class FlowAppLifecycle: @unchecked Sendable {
|
|
||||||
|
|
||||||
public static let shared = FlowAppLifecycle()
|
|
||||||
|
|
||||||
private let lock = NSLock()
|
|
||||||
private var isForeground = true
|
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
/// `true` when the host app scene is active (`.active`).
|
|
||||||
public var allowsGPUInference: Bool {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
return isForeground
|
|
||||||
}
|
|
||||||
|
|
||||||
public func setForeground(_ foreground: Bool) {
|
|
||||||
lock.lock()
|
|
||||||
isForeground = foreground
|
|
||||||
lock.unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Blocks until foreground or cancellation.
|
|
||||||
public func waitUntilForeground() async -> Bool {
|
|
||||||
while !allowsGPUInference {
|
|
||||||
if Task.isCancelled { return false }
|
|
||||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -163,6 +163,10 @@ public final class FlowContinuousCapture {
|
|||||||
private var didInstallTap = false
|
private var didInstallTap = false
|
||||||
private var isRunning = false
|
private var isRunning = false
|
||||||
|
|
||||||
|
private var routeObserver: NSObjectProtocol?
|
||||||
|
private var interruptionObserver: NSObjectProtocol?
|
||||||
|
private let log = Logger(subsystem: "com.osgkeyboard.shared", category: "FlowCapture")
|
||||||
|
|
||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
public var running: Bool { isRunning }
|
public var running: Bool { isRunning }
|
||||||
@@ -170,7 +174,16 @@ public final class FlowContinuousCapture {
|
|||||||
/// Configure `.playAndRecord`, install a permanent input tap, start the engine.
|
/// Configure `.playAndRecord`, install a permanent input tap, start the engine.
|
||||||
public func start() throws {
|
public func start() throws {
|
||||||
guard !isRunning else { return }
|
guard !isRunning else { return }
|
||||||
|
try activateEngine()
|
||||||
|
isRunning = true
|
||||||
|
installSessionObservers()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bring up the audio session + engine for the *current* hardware route.
|
||||||
|
/// Reused for route-change / interruption recovery, so it always rebuilds
|
||||||
|
/// the tap against the live hardware format (which changes when the user
|
||||||
|
/// plugs in AirPods or a wired headset mid-session).
|
||||||
|
private func activateEngine() throws {
|
||||||
let session = AVAudioSession.sharedInstance()
|
let session = AVAudioSession.sharedInstance()
|
||||||
do {
|
do {
|
||||||
try session.setCategory(
|
try session.setCategory(
|
||||||
@@ -204,23 +217,22 @@ public final class FlowContinuousCapture {
|
|||||||
throw StartError.converterCreateFailed
|
throw StartError.converterCreateFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
if !didInstallTap {
|
// Rebuild the tap so its bound hardware format matches the new route.
|
||||||
let utteranceFlag = isUtteranceActive
|
if didInstallTap {
|
||||||
let relay = streamRelay
|
inputNode.removeTap(onBus: 0)
|
||||||
let preroll = prerollStore
|
didInstallTap = false
|
||||||
let levels = levelStore
|
|
||||||
let tap = Self.makeAudioTapBlock(
|
|
||||||
converter: converter,
|
|
||||||
targetFormat: targetFormat,
|
|
||||||
hwFormat: hwFormat,
|
|
||||||
utteranceFlag: utteranceFlag,
|
|
||||||
levelStore: levels,
|
|
||||||
prerollStore: preroll,
|
|
||||||
streamRelay: relay
|
|
||||||
)
|
|
||||||
inputNode.installTap(onBus: 0, bufferSize: 4096, format: hwFormat, block: tap)
|
|
||||||
didInstallTap = true
|
|
||||||
}
|
}
|
||||||
|
let tap = Self.makeAudioTapBlock(
|
||||||
|
converter: converter,
|
||||||
|
targetFormat: targetFormat,
|
||||||
|
hwFormat: hwFormat,
|
||||||
|
utteranceFlag: isUtteranceActive,
|
||||||
|
levelStore: levelStore,
|
||||||
|
prerollStore: prerollStore,
|
||||||
|
streamRelay: streamRelay
|
||||||
|
)
|
||||||
|
inputNode.installTap(onBus: 0, bufferSize: 4096, format: hwFormat, block: tap)
|
||||||
|
didInstallTap = true
|
||||||
|
|
||||||
audioEngine.prepare()
|
audioEngine.prepare()
|
||||||
do {
|
do {
|
||||||
@@ -228,11 +240,11 @@ public final class FlowContinuousCapture {
|
|||||||
} catch {
|
} catch {
|
||||||
throw StartError.engineStartFailed(error.localizedDescription)
|
throw StartError.engineStartFailed(error.localizedDescription)
|
||||||
}
|
}
|
||||||
isRunning = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tear down the engine and release the audio session.
|
/// Tear down the engine and release the audio session.
|
||||||
public func stop() {
|
public func stop() {
|
||||||
|
removeSessionObservers()
|
||||||
isUtteranceActive.withLock { $0 = false }
|
isUtteranceActive.withLock { $0 = false }
|
||||||
streamRelay.finish()
|
streamRelay.finish()
|
||||||
|
|
||||||
@@ -266,6 +278,98 @@ public final class FlowContinuousCapture {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Route / interruption recovery
|
||||||
|
|
||||||
|
private func installSessionObservers() {
|
||||||
|
let center = NotificationCenter.default
|
||||||
|
if routeObserver == nil {
|
||||||
|
routeObserver = center.addObserver(
|
||||||
|
forName: AVAudioSession.routeChangeNotification,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] note in
|
||||||
|
// Extract Sendable primitives here (Notification isn't Sendable)
|
||||||
|
// before hopping onto the main actor.
|
||||||
|
let reasonRaw = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt
|
||||||
|
MainActor.assumeIsolated { self?.handleRouteChange(reasonRaw: reasonRaw) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if interruptionObserver == nil {
|
||||||
|
interruptionObserver = center.addObserver(
|
||||||
|
forName: AVAudioSession.interruptionNotification,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] note in
|
||||||
|
let typeRaw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt
|
||||||
|
let optionsRaw = note.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
self?.handleInterruption(typeRaw: typeRaw, optionsRaw: optionsRaw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeSessionObservers() {
|
||||||
|
let center = NotificationCenter.default
|
||||||
|
if let routeObserver { center.removeObserver(routeObserver) }
|
||||||
|
if let interruptionObserver { center.removeObserver(interruptionObserver) }
|
||||||
|
routeObserver = nil
|
||||||
|
interruptionObserver = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleRouteChange(reasonRaw: UInt?) {
|
||||||
|
guard isRunning else { return }
|
||||||
|
guard let reasonRaw,
|
||||||
|
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonRaw) else { return }
|
||||||
|
// Only rebuild for real device swaps (plugging / unplugging a headset
|
||||||
|
// or AirPods). Ignore `.categoryChange`, which we trigger ourselves.
|
||||||
|
switch reason {
|
||||||
|
case .oldDeviceUnavailable, .newDeviceAvailable:
|
||||||
|
log.info("Audio route changed (\(reasonRaw, privacy: .public)) — rebuilding engine")
|
||||||
|
rebuildEngine()
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleInterruption(typeRaw: UInt?, optionsRaw: UInt?) {
|
||||||
|
guard let typeRaw,
|
||||||
|
let type = AVAudioSession.InterruptionType(rawValue: typeRaw) else { return }
|
||||||
|
switch type {
|
||||||
|
case .began:
|
||||||
|
// The system already paused our engine; wait for `.ended`.
|
||||||
|
log.info("Audio interruption began")
|
||||||
|
case .ended:
|
||||||
|
guard isRunning else { return }
|
||||||
|
let shouldResume: Bool
|
||||||
|
if let optionsRaw {
|
||||||
|
shouldResume = AVAudioSession.InterruptionOptions(rawValue: optionsRaw).contains(.shouldResume)
|
||||||
|
} else {
|
||||||
|
shouldResume = true
|
||||||
|
}
|
||||||
|
if shouldResume {
|
||||||
|
log.info("Audio interruption ended — resuming capture")
|
||||||
|
rebuildEngine()
|
||||||
|
}
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop and rebuild the engine against the current route, keeping
|
||||||
|
/// `isRunning` intact so the session survives the swap transparently.
|
||||||
|
private func rebuildEngine() {
|
||||||
|
guard isRunning else { return }
|
||||||
|
if audioEngine.isRunning {
|
||||||
|
audioEngine.stop()
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try activateEngine()
|
||||||
|
} catch {
|
||||||
|
log.error("Engine rebuild failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Begin forwarding downsampled buffers to ASR for one utterance.
|
/// Begin forwarding downsampled buffers to ASR for one utterance.
|
||||||
public func beginUtterance() -> AsyncStream<AudioBufferSnapshot> {
|
public func beginUtterance() -> AsyncStream<AudioBufferSnapshot> {
|
||||||
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
|
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
|
||||||
|
|||||||
@@ -2,15 +2,31 @@
|
|||||||
// OSGKeyboard · Shared
|
// OSGKeyboard · Shared
|
||||||
//
|
//
|
||||||
// TypeWhisper-style Flow session bridge: keyboard writes recording
|
// TypeWhisper-style Flow session bridge: keyboard writes recording
|
||||||
// signals; host app writes transcription results. Legacy one-shot
|
// signals; host app writes transcription results.
|
||||||
// dictation handoff remains in `DictationBridge`.
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
public struct FlowTranscriptionError: Equatable, Sendable {
|
||||||
|
public let message: String
|
||||||
|
public let kind: FlowSessionKeys.TranscriptionErrorKind
|
||||||
|
|
||||||
|
public init(message: String, kind: FlowSessionKeys.TranscriptionErrorKind) {
|
||||||
|
self.message = message
|
||||||
|
self.kind = kind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public enum FlowSessionBridge {
|
public enum FlowSessionBridge {
|
||||||
private static func resolvedDefaults(_ defaults: UserDefaults?) -> UserDefaults {
|
private static func resolvedDefaults(_ defaults: UserDefaults?) -> UserDefaults {
|
||||||
if let defaults { return defaults }
|
if let defaults { return defaults }
|
||||||
return AppGroup.isAvailable ? AppGroup.defaults : .standard
|
guard let available = AppGroup.defaultsIfAvailable else {
|
||||||
|
#if DEBUG
|
||||||
|
fatalError("App Group unavailable — inject UserDefaults in tests or fix entitlements.")
|
||||||
|
#else
|
||||||
|
fatalError("App Group unavailable.")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
return available
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Force cross-process visibility. Must only be called on the main thread.
|
/// Force cross-process visibility. Must only be called on the main thread.
|
||||||
@@ -23,13 +39,16 @@ public enum FlowSessionBridge {
|
|||||||
// MARK: - Session lifecycle (host app)
|
// MARK: - Session lifecycle (host app)
|
||||||
|
|
||||||
public static func markSessionActive(
|
public static func markSessionActive(
|
||||||
duration: TimeInterval = FlowSessionKeys.defaultSessionDuration,
|
duration: TimeInterval? = nil,
|
||||||
defaults: UserDefaults? = nil
|
defaults: UserDefaults? = nil
|
||||||
) {
|
) {
|
||||||
let store = resolvedDefaults(defaults)
|
let store = resolvedDefaults(defaults)
|
||||||
let expires = Date().timeIntervalSince1970 + duration
|
let resolvedDuration = duration ?? FlowSessionPolicy.sessionDuration(defaults: store)
|
||||||
|
let now = Date().timeIntervalSince1970
|
||||||
|
let expires = now + resolvedDuration
|
||||||
store.set(true, forKey: FlowSessionKeys.flowSessionActive)
|
store.set(true, forKey: FlowSessionKeys.flowSessionActive)
|
||||||
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
|
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
|
||||||
|
store.set(now, forKey: FlowSessionKeys.lastActivityAt)
|
||||||
writeHeartbeat(defaults: store)
|
writeHeartbeat(defaults: store)
|
||||||
setRecordingState(.idle, defaults: store)
|
setRecordingState(.idle, defaults: store)
|
||||||
clearTranscription(defaults: store)
|
clearTranscription(defaults: store)
|
||||||
@@ -53,16 +72,49 @@ public enum FlowSessionBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static func extendSession(
|
public static func extendSession(
|
||||||
by duration: TimeInterval = FlowSessionKeys.defaultSessionDuration,
|
by duration: TimeInterval? = nil,
|
||||||
defaults: UserDefaults? = nil
|
defaults: UserDefaults? = nil
|
||||||
) {
|
) {
|
||||||
let store = resolvedDefaults(defaults)
|
let store = resolvedDefaults(defaults)
|
||||||
let expires = Date().timeIntervalSince1970 + duration
|
let resolvedDuration = duration ?? FlowSessionPolicy.sessionDuration(defaults: store)
|
||||||
|
let expires = Date().timeIntervalSince1970 + resolvedDuration
|
||||||
store.set(true, forKey: FlowSessionKeys.flowSessionActive)
|
store.set(true, forKey: FlowSessionKeys.flowSessionActive)
|
||||||
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
|
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
|
||||||
flush(store)
|
flush(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resets the inactivity timer after utterance completion or explicit activity.
|
||||||
|
public static func touchLastActivity(defaults: UserDefaults? = nil) {
|
||||||
|
let store = resolvedDefaults(defaults)
|
||||||
|
let now = Date().timeIntervalSince1970
|
||||||
|
let duration = FlowSessionPolicy.sessionDuration(defaults: store)
|
||||||
|
store.set(now, forKey: FlowSessionKeys.lastActivityAt)
|
||||||
|
store.set(now + duration, forKey: FlowSessionKeys.flowSessionExpires)
|
||||||
|
store.set(true, forKey: FlowSessionKeys.flowSessionActive)
|
||||||
|
flush(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Host return (scheme D)
|
||||||
|
|
||||||
|
public static func setPendingHostBundleId(_ bundleId: String?, defaults: UserDefaults? = nil) {
|
||||||
|
let store = resolvedDefaults(defaults)
|
||||||
|
if let bundleId, !bundleId.isEmpty {
|
||||||
|
store.set(bundleId, forKey: FlowSessionKeys.pendingHostBundleId)
|
||||||
|
} else {
|
||||||
|
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId)
|
||||||
|
}
|
||||||
|
flush(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func pendingHostBundleId(defaults: UserDefaults? = nil) -> String? {
|
||||||
|
let store = resolvedDefaults(defaults)
|
||||||
|
return store.string(forKey: FlowSessionKeys.pendingHostBundleId)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func clearPendingHostBundleId(defaults: UserDefaults? = nil) {
|
||||||
|
setPendingHostBundleId(nil, defaults: defaults)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Session validity (keyboard)
|
// MARK: - Session validity (keyboard)
|
||||||
|
|
||||||
/// True when the session contract is still valid (not expired).
|
/// True when the session contract is still valid (not expired).
|
||||||
@@ -70,7 +122,6 @@ public enum FlowSessionBridge {
|
|||||||
/// background while the continuous audio session is frozen.
|
/// background while the continuous audio session is frozen.
|
||||||
public static func isSessionActive(defaults: UserDefaults? = nil) -> Bool {
|
public static func isSessionActive(defaults: UserDefaults? = nil) -> Bool {
|
||||||
let store = resolvedDefaults(defaults)
|
let store = resolvedDefaults(defaults)
|
||||||
flush(store)
|
|
||||||
guard store.bool(forKey: FlowSessionKeys.flowSessionActive) else { return false }
|
guard store.bool(forKey: FlowSessionKeys.flowSessionActive) else { return false }
|
||||||
|
|
||||||
let expires = store.double(forKey: FlowSessionKeys.flowSessionExpires)
|
let expires = store.double(forKey: FlowSessionKeys.flowSessionExpires)
|
||||||
@@ -81,7 +132,6 @@ public enum FlowSessionBridge {
|
|||||||
/// actively processing). Used for auto-start heuristics, not gating record.
|
/// actively processing). Used for auto-start heuristics, not gating record.
|
||||||
public static func isHostReachable(defaults: UserDefaults? = nil) -> Bool {
|
public static func isHostReachable(defaults: UserDefaults? = nil) -> Bool {
|
||||||
let store = resolvedDefaults(defaults)
|
let store = resolvedDefaults(defaults)
|
||||||
flush(store)
|
|
||||||
guard isSessionActive(defaults: store) else { return false }
|
guard isSessionActive(defaults: store) else { return false }
|
||||||
|
|
||||||
let heartbeat = store.double(forKey: FlowSessionKeys.flowHeartbeat)
|
let heartbeat = store.double(forKey: FlowSessionKeys.flowHeartbeat)
|
||||||
@@ -144,6 +194,7 @@ public enum FlowSessionBridge {
|
|||||||
let store = resolvedDefaults(defaults)
|
let store = resolvedDefaults(defaults)
|
||||||
store.set(trimmed, forKey: FlowSessionKeys.transcriptionResult)
|
store.set(trimmed, forKey: FlowSessionKeys.transcriptionResult)
|
||||||
store.removeObject(forKey: FlowSessionKeys.transcriptionError)
|
store.removeObject(forKey: FlowSessionKeys.transcriptionError)
|
||||||
|
store.removeObject(forKey: FlowSessionKeys.transcriptionPartial)
|
||||||
if let polishWarning, !polishWarning.isEmpty {
|
if let polishWarning, !polishWarning.isEmpty {
|
||||||
store.set(polishWarning, forKey: FlowSessionKeys.transcriptionPolishWarning)
|
store.set(polishWarning, forKey: FlowSessionKeys.transcriptionPolishWarning)
|
||||||
} else {
|
} else {
|
||||||
@@ -151,16 +202,46 @@ public enum FlowSessionBridge {
|
|||||||
}
|
}
|
||||||
setRecordingState(.idle, defaults: store)
|
setRecordingState(.idle, defaults: store)
|
||||||
flush(store)
|
flush(store)
|
||||||
|
FlowSessionDarwin.postTranscriptionChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host app: publish pipelined ASR partial while recording or finalizing.
|
||||||
|
public static func storeTranscriptionPartial(
|
||||||
|
_ text: String,
|
||||||
|
defaults: UserDefaults? = nil
|
||||||
|
) {
|
||||||
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let store = resolvedDefaults(defaults)
|
||||||
|
if trimmed.isEmpty {
|
||||||
|
store.removeObject(forKey: FlowSessionKeys.transcriptionPartial)
|
||||||
|
} else {
|
||||||
|
store.set(trimmed, forKey: FlowSessionKeys.transcriptionPartial)
|
||||||
|
}
|
||||||
|
flush(store)
|
||||||
|
FlowSessionDarwin.postTranscriptionChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keyboard: read the latest partial without clearing it.
|
||||||
|
public static func transcriptionPartial(defaults: UserDefaults? = nil) -> String? {
|
||||||
|
let store = resolvedDefaults(defaults)
|
||||||
|
guard let text = store.string(forKey: FlowSessionKeys.transcriptionPartial),
|
||||||
|
!text.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func storeTranscriptionError(
|
public static func storeTranscriptionError(
|
||||||
_ message: String,
|
_ message: String,
|
||||||
|
kind: FlowSessionKeys.TranscriptionErrorKind = .generic,
|
||||||
defaults: UserDefaults? = nil
|
defaults: UserDefaults? = nil
|
||||||
) {
|
) {
|
||||||
let store = resolvedDefaults(defaults)
|
let store = resolvedDefaults(defaults)
|
||||||
store.set(message, forKey: FlowSessionKeys.transcriptionError)
|
store.set(message, forKey: FlowSessionKeys.transcriptionError)
|
||||||
|
store.set(kind.rawValue, forKey: FlowSessionKeys.transcriptionErrorKind)
|
||||||
setRecordingState(.idle, defaults: store)
|
setRecordingState(.idle, defaults: store)
|
||||||
flush(store)
|
flush(store)
|
||||||
|
FlowSessionDarwin.postTranscriptionChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns and clears a pending transcription result, if any.
|
/// Returns and clears a pending transcription result, if any.
|
||||||
@@ -174,7 +255,6 @@ public enum FlowSessionBridge {
|
|||||||
defaults: UserDefaults? = nil
|
defaults: UserDefaults? = nil
|
||||||
) -> TranscriptionDelivery? {
|
) -> TranscriptionDelivery? {
|
||||||
let store = resolvedDefaults(defaults)
|
let store = resolvedDefaults(defaults)
|
||||||
flush(store)
|
|
||||||
guard let text = store.string(forKey: FlowSessionKeys.transcriptionResult), !text.isEmpty else {
|
guard let text = store.string(forKey: FlowSessionKeys.transcriptionResult), !text.isEmpty else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -186,20 +266,21 @@ public enum FlowSessionBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns and clears a pending transcription error, if any.
|
/// Returns and clears a pending transcription error, if any.
|
||||||
public static func consumeTranscriptionError(defaults: UserDefaults? = nil) -> String? {
|
public static func consumeTranscriptionError(defaults: UserDefaults? = nil) -> FlowTranscriptionError? {
|
||||||
let store = resolvedDefaults(defaults)
|
let store = resolvedDefaults(defaults)
|
||||||
flush(store)
|
|
||||||
guard let message = store.string(forKey: FlowSessionKeys.transcriptionError), !message.isEmpty else {
|
guard let message = store.string(forKey: FlowSessionKeys.transcriptionError), !message.isEmpty else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
let kindRaw = store.string(forKey: FlowSessionKeys.transcriptionErrorKind)
|
||||||
|
let kind = FlowSessionKeys.TranscriptionErrorKind(rawValue: kindRaw ?? "") ?? .generic
|
||||||
store.removeObject(forKey: FlowSessionKeys.transcriptionError)
|
store.removeObject(forKey: FlowSessionKeys.transcriptionError)
|
||||||
|
store.removeObject(forKey: FlowSessionKeys.transcriptionErrorKind)
|
||||||
flush(store)
|
flush(store)
|
||||||
return message
|
return FlowTranscriptionError(message: message, kind: kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func audioLevels(defaults: UserDefaults? = nil) -> [Float] {
|
public static func audioLevels(defaults: UserDefaults? = nil) -> [Float] {
|
||||||
let store = resolvedDefaults(defaults)
|
let store = resolvedDefaults(defaults)
|
||||||
flush(store)
|
|
||||||
if let levels = store.array(forKey: FlowSessionKeys.audioLevels) as? [Double], !levels.isEmpty {
|
if let levels = store.array(forKey: FlowSessionKeys.audioLevels) as? [Double], !levels.isEmpty {
|
||||||
return levels.map { Float($0) }
|
return levels.map { Float($0) }
|
||||||
}
|
}
|
||||||
@@ -235,12 +316,16 @@ public enum FlowSessionBridge {
|
|||||||
store.removeObject(forKey: FlowSessionKeys.transcriptionLanguage)
|
store.removeObject(forKey: FlowSessionKeys.transcriptionLanguage)
|
||||||
clearTranscription(defaults: store)
|
clearTranscription(defaults: store)
|
||||||
store.removeObject(forKey: FlowSessionKeys.audioLevels)
|
store.removeObject(forKey: FlowSessionKeys.audioLevels)
|
||||||
|
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId)
|
||||||
|
store.removeObject(forKey: FlowSessionKeys.lastActivityAt)
|
||||||
flush(store)
|
flush(store)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func clearTranscription(defaults: UserDefaults) {
|
private static func clearTranscription(defaults: UserDefaults) {
|
||||||
defaults.removeObject(forKey: FlowSessionKeys.transcriptionResult)
|
defaults.removeObject(forKey: FlowSessionKeys.transcriptionResult)
|
||||||
|
defaults.removeObject(forKey: FlowSessionKeys.transcriptionPartial)
|
||||||
defaults.removeObject(forKey: FlowSessionKeys.transcriptionPolishWarning)
|
defaults.removeObject(forKey: FlowSessionKeys.transcriptionPolishWarning)
|
||||||
defaults.removeObject(forKey: FlowSessionKeys.transcriptionError)
|
defaults.removeObject(forKey: FlowSessionKeys.transcriptionError)
|
||||||
|
defaults.removeObject(forKey: FlowSessionKeys.transcriptionErrorKind)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import Foundation
|
|||||||
|
|
||||||
public enum FlowSessionDarwin {
|
public enum FlowSessionDarwin {
|
||||||
public static let notificationName = "com.osgkeyboard.flow.session.changed"
|
public static let notificationName = "com.osgkeyboard.flow.session.changed"
|
||||||
|
/// Posted when the host app writes a transcription result or error.
|
||||||
|
public static let transcriptionNotificationName = "com.osgkeyboard.flow.transcription.changed"
|
||||||
|
|
||||||
public static func postSessionChanged() {
|
public static func postSessionChanged() {
|
||||||
CFNotificationCenterPostNotification(
|
CFNotificationCenterPostNotification(
|
||||||
@@ -18,6 +20,16 @@ public enum FlowSessionDarwin {
|
|||||||
true
|
true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static func postTranscriptionChanged() {
|
||||||
|
CFNotificationCenterPostNotification(
|
||||||
|
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||||
|
CFNotificationName(transcriptionNotificationName as CFString),
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Observes Darwin notifications on a background thread; invokes
|
/// Observes Darwin notifications on a background thread; invokes
|
||||||
|
|||||||
@@ -13,15 +13,23 @@ public enum FlowSessionKeys {
|
|||||||
public static let keyboardRecordingState = "flow.keyboardRecordingState"
|
public static let keyboardRecordingState = "flow.keyboardRecordingState"
|
||||||
public static let transcriptionLanguage = "flow.transcriptionLanguage"
|
public static let transcriptionLanguage = "flow.transcriptionLanguage"
|
||||||
public static let transcriptionResult = "flow.transcriptionResult"
|
public static let transcriptionResult = "flow.transcriptionResult"
|
||||||
|
/// Live pipelined ASR partial for the keyboard transcript line.
|
||||||
|
public static let transcriptionPartial = "flow.transcriptionPartial"
|
||||||
/// Soft warning when polish failed but raw transcript was delivered.
|
/// Soft warning when polish failed but raw transcript was delivered.
|
||||||
public static let transcriptionPolishWarning = "flow.transcriptionPolishWarning"
|
public static let transcriptionPolishWarning = "flow.transcriptionPolishWarning"
|
||||||
public static let transcriptionError = "flow.transcriptionError"
|
public static let transcriptionError = "flow.transcriptionError"
|
||||||
|
/// Structured kind paired with `transcriptionError` for keyboard UI.
|
||||||
|
public static let transcriptionErrorKind = "flow.transcriptionErrorKind"
|
||||||
public static let audioLevels = "flow.audioLevels"
|
public static let audioLevels = "flow.audioLevels"
|
||||||
|
/// Bundle id of the app that opened `osgkeyboard://startflow` (scheme D).
|
||||||
|
public static let pendingHostBundleId = "flow.pendingHostBundleId"
|
||||||
|
/// Wall-clock timestamp of the last utterance completion or session start.
|
||||||
|
public static let lastActivityAt = "flow.lastActivityAt"
|
||||||
|
|
||||||
/// Heartbeat older than this while the host is foreground → likely killed.
|
/// Heartbeat older than this while the host is foreground → likely killed.
|
||||||
public static let heartbeatStaleInterval: TimeInterval = 3
|
public static let heartbeatStaleInterval: TimeInterval = 3
|
||||||
|
|
||||||
/// Default Flow session length when started from the keyboard.
|
/// Legacy fixed session length — prefer `FlowSessionPolicy.sessionDuration()`.
|
||||||
public static let defaultSessionDuration: TimeInterval = 480
|
public static let defaultSessionDuration: TimeInterval = 480
|
||||||
|
|
||||||
/// Maximum duration for a single keyboard utterance (3.5 minutes).
|
/// Maximum duration for a single keyboard utterance (3.5 minutes).
|
||||||
@@ -36,15 +44,7 @@ public enum FlowSessionKeys {
|
|||||||
/// Keyboard watchdog after the user stops recording (not utterance max length).
|
/// Keyboard watchdog after the user stops recording (not utterance max length).
|
||||||
/// Must cover worst-case post-stop backlog: remaining SpeechAnalyzer chunks
|
/// Must cover worst-case post-stop backlog: remaining SpeechAnalyzer chunks
|
||||||
/// plus cloud LLM polish (see `PolishingService.effectiveTimeout` cap).
|
/// plus cloud LLM polish (see `PolishingService.effectiveTimeout` cap).
|
||||||
///
|
public static func keyboardResultTimeout(engineMode: String) -> TimeInterval {
|
||||||
/// As of v0.2.0 the local engine uses iOS `SpeechAnalyzer` only, so the
|
|
||||||
/// previous Qwen3-specific timeout (240 s) collapses into the shared
|
|
||||||
/// local path. We keep `localASRBackend` on the signature for symmetry
|
|
||||||
/// with other shared helpers.
|
|
||||||
public static func keyboardResultTimeout(
|
|
||||||
engineMode: String,
|
|
||||||
localASRBackend: LocalASRBackend
|
|
||||||
) -> TimeInterval {
|
|
||||||
if engineMode == "local" {
|
if engineMode == "local" {
|
||||||
return 180
|
return 180
|
||||||
}
|
}
|
||||||
@@ -58,4 +58,13 @@ public enum FlowSessionKeys {
|
|||||||
case processing
|
case processing
|
||||||
case aborted
|
case aborted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Structured host → keyboard transcription failure kind.
|
||||||
|
public enum TranscriptionErrorKind: String, Sendable, Equatable {
|
||||||
|
case noSpeech
|
||||||
|
case recognitionInterrupted
|
||||||
|
case audioUnavailable
|
||||||
|
case asrFailed
|
||||||
|
case generic
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// FlowSessionPolicy.swift
|
||||||
|
// OSGKeyboard · Shared
|
||||||
|
//
|
||||||
|
// Reads Flow session behaviour preferences from the App Group.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum FlowSessionPolicy {
|
||||||
|
public static func skipAppSwitch(defaults: UserDefaults? = nil) -> Bool {
|
||||||
|
let store = resolvedDefaults(defaults)
|
||||||
|
if store.object(forKey: AppGroupConfiguration.Keys.flowSkipAppSwitch) == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return store.bool(forKey: AppGroupConfiguration.Keys.flowSkipAppSwitch)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func inactivityDuration(defaults: UserDefaults? = nil) -> FlowInactivityDuration {
|
||||||
|
let store = resolvedDefaults(defaults)
|
||||||
|
return FlowInactivityDuration.fromStored(
|
||||||
|
store.string(forKey: AppGroupConfiguration.Keys.flowInactivityDuration)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func sessionDuration(defaults: UserDefaults? = nil) -> TimeInterval {
|
||||||
|
inactivityDuration(defaults: defaults).timeInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolvedDefaults(_ defaults: UserDefaults?) -> UserDefaults {
|
||||||
|
if let defaults { return defaults }
|
||||||
|
guard let available = AppGroup.defaultsIfAvailable else {
|
||||||
|
#if DEBUG
|
||||||
|
fatalError("App Group unavailable — inject UserDefaults in tests.")
|
||||||
|
#else
|
||||||
|
fatalError("App Group unavailable.")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
return available
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
// HostAppURLRegistry.swift
|
||||||
|
// OSGKeyboard · Shared
|
||||||
|
//
|
||||||
|
// Public URL-scheme whitelist for returning to a known host app after a
|
||||||
|
// cold-start Flow session handoff. One bundle id maps to one preferred URL.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct HostAppEntry: Sendable, Equatable {
|
||||||
|
public let bundleId: String
|
||||||
|
public let displayNameKey: String
|
||||||
|
public let returnURLString: String
|
||||||
|
public let tier: Int
|
||||||
|
|
||||||
|
public var returnURL: URL? {
|
||||||
|
URL(string: returnURLString)
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(bundleId: String, displayNameKey: String, returnURLString: String, tier: Int) {
|
||||||
|
self.bundleId = bundleId
|
||||||
|
self.displayNameKey = displayNameKey
|
||||||
|
self.returnURLString = returnURLString
|
||||||
|
self.tier = tier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum HostAppURLRegistry {
|
||||||
|
/// Curated whitelist for high-frequency host apps (IM, work, notes).
|
||||||
|
public static let entries: [HostAppEntry] = [
|
||||||
|
// Tier 1 — China IM / work
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.tencent.xin",
|
||||||
|
displayNameKey: "hostApp.wechat",
|
||||||
|
returnURLString: "weixin://",
|
||||||
|
tier: 1
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.tencent.mqq",
|
||||||
|
displayNameKey: "hostApp.qq",
|
||||||
|
returnURLString: "mqq://",
|
||||||
|
tier: 1
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.tencent.wework",
|
||||||
|
displayNameKey: "hostApp.wecom",
|
||||||
|
returnURLString: "wxwork://",
|
||||||
|
tier: 1
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.laiwang.DingTalk",
|
||||||
|
displayNameKey: "hostApp.dingtalk",
|
||||||
|
returnURLString: "dingtalk://",
|
||||||
|
tier: 1
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.bytedance.ee.lark",
|
||||||
|
displayNameKey: "hostApp.lark",
|
||||||
|
returnURLString: "lark://",
|
||||||
|
tier: 1
|
||||||
|
),
|
||||||
|
// Tier 2 — global IM / collaboration
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "ph.telegra.Telegraph",
|
||||||
|
displayNameKey: "hostApp.telegram",
|
||||||
|
returnURLString: "tg://",
|
||||||
|
tier: 2
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "net.whatsapp.WhatsApp",
|
||||||
|
displayNameKey: "hostApp.whatsapp",
|
||||||
|
returnURLString: "whatsapp://",
|
||||||
|
tier: 2
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "jp.naver.line",
|
||||||
|
displayNameKey: "hostApp.line",
|
||||||
|
returnURLString: "line://",
|
||||||
|
tier: 2
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.facebook.Messenger",
|
||||||
|
displayNameKey: "hostApp.messenger",
|
||||||
|
returnURLString: "fb-messenger://",
|
||||||
|
tier: 2
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.tinyspeck.chatlyio",
|
||||||
|
displayNameKey: "hostApp.slack",
|
||||||
|
returnURLString: "slack://",
|
||||||
|
tier: 2
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.microsoft.skype.teams",
|
||||||
|
displayNameKey: "hostApp.teams",
|
||||||
|
returnURLString: "msteams://",
|
||||||
|
tier: 2
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.hammerandchisel.discord",
|
||||||
|
displayNameKey: "hostApp.discord",
|
||||||
|
returnURLString: "discord://",
|
||||||
|
tier: 2
|
||||||
|
),
|
||||||
|
// Tier 3 — notes / mail / browser
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "notion.id",
|
||||||
|
displayNameKey: "hostApp.notion",
|
||||||
|
returnURLString: "notion://",
|
||||||
|
tier: 3
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "net.shinyfrog.bear",
|
||||||
|
displayNameKey: "hostApp.bear",
|
||||||
|
returnURLString: "bear://",
|
||||||
|
tier: 3
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "md.obsidian",
|
||||||
|
displayNameKey: "hostApp.obsidian",
|
||||||
|
returnURLString: "obsidian://",
|
||||||
|
tier: 3
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.agiletortoise.Drafts5",
|
||||||
|
displayNameKey: "hostApp.drafts",
|
||||||
|
returnURLString: "drafts5://",
|
||||||
|
tier: 3
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.google.Gmail",
|
||||||
|
displayNameKey: "hostApp.gmail",
|
||||||
|
returnURLString: "googlegmail://",
|
||||||
|
tier: 3
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.microsoft.Office.Outlook",
|
||||||
|
displayNameKey: "hostApp.outlook",
|
||||||
|
returnURLString: "ms-outlook://",
|
||||||
|
tier: 3
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.google.chrome.ios",
|
||||||
|
displayNameKey: "hostApp.chrome",
|
||||||
|
returnURLString: "googlechrome://",
|
||||||
|
tier: 3
|
||||||
|
),
|
||||||
|
// Tier 4 — China social
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.sina.weibo",
|
||||||
|
displayNameKey: "hostApp.weibo",
|
||||||
|
returnURLString: "sinaweibo://",
|
||||||
|
tier: 4
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.xingin.discover",
|
||||||
|
displayNameKey: "hostApp.xiaohongshu",
|
||||||
|
returnURLString: "xhsdiscover://",
|
||||||
|
tier: 4
|
||||||
|
),
|
||||||
|
// Tier 2 (cont.) — global IM / calls
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "org.whispersystems.signal",
|
||||||
|
displayNameKey: "hostApp.signal",
|
||||||
|
returnURLString: "sgnl://",
|
||||||
|
tier: 2
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.iwilab.KakaoTalk",
|
||||||
|
displayNameKey: "hostApp.kakaotalk",
|
||||||
|
returnURLString: "kakaotalk://",
|
||||||
|
tier: 2
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.viber",
|
||||||
|
displayNameKey: "hostApp.viber",
|
||||||
|
returnURLString: "viber://",
|
||||||
|
tier: 2
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.vng.zaloapp",
|
||||||
|
displayNameKey: "hostApp.zalo",
|
||||||
|
returnURLString: "zalo://",
|
||||||
|
tier: 2
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.skype.skype",
|
||||||
|
displayNameKey: "hostApp.skype",
|
||||||
|
returnURLString: "skype://",
|
||||||
|
tier: 2
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "us.zoom.videomeetings",
|
||||||
|
displayNameKey: "hostApp.zoom",
|
||||||
|
returnURLString: "zoomus://",
|
||||||
|
tier: 2
|
||||||
|
),
|
||||||
|
// Tier 3 (cont.) — notes / mail / browser
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.culturedcode.ThingsiPhone",
|
||||||
|
displayNameKey: "hostApp.things",
|
||||||
|
returnURLString: "things://",
|
||||||
|
tier: 3
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.todoist.ios",
|
||||||
|
displayNameKey: "hostApp.todoist",
|
||||||
|
returnURLString: "todoist://",
|
||||||
|
tier: 3
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.evernote.iPhone.Evernote",
|
||||||
|
displayNameKey: "hostApp.evernote",
|
||||||
|
returnURLString: "evernote://",
|
||||||
|
tier: 3
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.microsoft.onenote",
|
||||||
|
displayNameKey: "hostApp.onenote",
|
||||||
|
returnURLString: "onenote://",
|
||||||
|
tier: 3
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.readdle.smartemail",
|
||||||
|
displayNameKey: "hostApp.spark",
|
||||||
|
returnURLString: "readdle-spark://",
|
||||||
|
tier: 3
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "org.mozilla.ios.Firefox",
|
||||||
|
displayNameKey: "hostApp.firefox",
|
||||||
|
returnURLString: "firefox://",
|
||||||
|
tier: 3
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.microsoft.msedge",
|
||||||
|
displayNameKey: "hostApp.edge",
|
||||||
|
returnURLString: "microsoft-edge://",
|
||||||
|
tier: 3
|
||||||
|
),
|
||||||
|
// Tier 4 (cont.) — global / China social
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.facebook.Facebook",
|
||||||
|
displayNameKey: "hostApp.facebook",
|
||||||
|
returnURLString: "fb://",
|
||||||
|
tier: 4
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.burbn.instagram",
|
||||||
|
displayNameKey: "hostApp.instagram",
|
||||||
|
returnURLString: "instagram://",
|
||||||
|
tier: 4
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.atebits.Tweetie2",
|
||||||
|
displayNameKey: "hostApp.x",
|
||||||
|
returnURLString: "twitter://",
|
||||||
|
tier: 4
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.burbn.barcelona",
|
||||||
|
displayNameKey: "hostApp.threads",
|
||||||
|
returnURLString: "barcelona://",
|
||||||
|
tier: 4
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.toyopagroup.picaboo",
|
||||||
|
displayNameKey: "hostApp.snapchat",
|
||||||
|
returnURLString: "snapchat://",
|
||||||
|
tier: 4
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.reddit.Reddit",
|
||||||
|
displayNameKey: "hostApp.reddit",
|
||||||
|
returnURLString: "reddit://",
|
||||||
|
tier: 4
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "pinterest",
|
||||||
|
displayNameKey: "hostApp.pinterest",
|
||||||
|
returnURLString: "pinterest://",
|
||||||
|
tier: 4
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.zhihu.ios",
|
||||||
|
displayNameKey: "hostApp.zhihu",
|
||||||
|
returnURLString: "zhihu://",
|
||||||
|
tier: 4
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "tv.danmaku.bili",
|
||||||
|
displayNameKey: "hostApp.bilibili",
|
||||||
|
returnURLString: "bilibili://",
|
||||||
|
tier: 4
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.ss.iphone.ugc.Aweme",
|
||||||
|
displayNameKey: "hostApp.douyin",
|
||||||
|
returnURLString: "snssdk1128://",
|
||||||
|
tier: 4
|
||||||
|
),
|
||||||
|
HostAppEntry(
|
||||||
|
bundleId: "com.zhiliaoapp.musically",
|
||||||
|
displayNameKey: "hostApp.tiktok",
|
||||||
|
returnURLString: "tiktok://",
|
||||||
|
tier: 4
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
private static let byBundleId: [String: HostAppEntry] = {
|
||||||
|
Dictionary(uniqueKeysWithValues: entries.map { ($0.bundleId, $0) })
|
||||||
|
}()
|
||||||
|
|
||||||
|
public static func lookup(bundleId: String?) -> HostAppEntry? {
|
||||||
|
guard let bundleId, !bundleId.isEmpty else { return nil }
|
||||||
|
return byBundleId[bundleId]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// URL schemes declared in `LSApplicationQueriesSchemes` for `canOpenURL`.
|
||||||
|
public static var querySchemes: [String] {
|
||||||
|
Array(
|
||||||
|
Set(
|
||||||
|
entries.compactMap { entry -> String? in
|
||||||
|
guard let url = entry.returnURL, let scheme = url.scheme else { return nil }
|
||||||
|
return scheme
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).sorted()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,26 +35,37 @@ public final class KeyboardState: ObservableObject {
|
|||||||
case asr(String)
|
case asr(String)
|
||||||
case llm(LLMError)
|
case llm(LLMError)
|
||||||
case appGroupUnavailable
|
case appGroupUnavailable
|
||||||
|
/// Keyboard extension lacks Full Access for host-app jumps.
|
||||||
|
case fullAccessRequired
|
||||||
|
/// Auto-jump to the host app failed; user must open it manually.
|
||||||
|
case manualOpenRequired
|
||||||
|
/// Host delivered raw transcript; polish step failed or was skipped.
|
||||||
|
case polishDegraded(String)
|
||||||
|
/// Host ASR finished with no usable speech.
|
||||||
|
case noSpeechDetected
|
||||||
|
/// Host ASR was interrupted before a final transcript arrived.
|
||||||
|
case recognitionInterrupted
|
||||||
|
/// Host could not start background audio capture.
|
||||||
|
case hostAudioUnavailable
|
||||||
|
/// Host ASR or pipeline failed with a user-facing message.
|
||||||
|
case hostTranscriptionFailed(String)
|
||||||
|
/// Flow result did not arrive before the keyboard watchdog expired.
|
||||||
|
case flowResultTimeout
|
||||||
|
/// Host Flow session ended while the keyboard was idle.
|
||||||
|
case flowSessionExpired
|
||||||
case unknown(String)
|
case unknown(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum Reason: Equatable { case mic, speech }
|
public enum Reason: Equatable { case mic, speech }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Voice input always runs through polish; legacy off/transcribe modes removed.
|
||||||
public enum InputMode: String, CaseIterable, Identifiable {
|
public enum InputMode: String, CaseIterable, Identifiable {
|
||||||
case off
|
|
||||||
case transcribe
|
|
||||||
case polish
|
case polish
|
||||||
|
|
||||||
public var id: String { rawValue }
|
public var id: String { rawValue }
|
||||||
|
|
||||||
public var labelKey: String {
|
public var labelKey: String { "mode.polish" }
|
||||||
switch self {
|
|
||||||
case .off: return "mode.off"
|
|
||||||
case .transcribe: return "mode.transcribe"
|
|
||||||
case .polish: return "mode.polish"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Published public var phase: Phase = .idle
|
@Published public var phase: Phase = .idle
|
||||||
@@ -78,20 +89,6 @@ public final class KeyboardState: ObservableObject {
|
|||||||
@Published public var micDisabledHint: String = ""
|
@Published public var micDisabledHint: String = ""
|
||||||
/// "local" → on-device ASR only. "cloud" → ASR + LLM polish.
|
/// "local" → on-device ASR only. "cloud" → ASR + LLM polish.
|
||||||
@Published public var engineMode: String = "cloud"
|
@Published public var engineMode: String = "cloud"
|
||||||
/// Which on-device ASR engine to use when `engineMode == "local"`.
|
|
||||||
/// Mirrored from `ProviderConfig.localASRBackend` for UI display
|
|
||||||
/// and for `state` consumers that want a single source of truth.
|
|
||||||
@Published public var localASRBackend: LocalASRBackend = .speechAnalyzer
|
|
||||||
/// v0.2.0: kept for source compatibility with the previous Qwen3
|
|
||||||
/// CoreML local engine. Always `true` now — iOS `SpeechAnalyzer`
|
|
||||||
/// ships with iOS 26 and has no per-user weights to download or
|
|
||||||
/// preload. Existing read sites will see `true` and behave the
|
|
||||||
/// same as the "stack ready" branch did.
|
|
||||||
@Published public var localModelsReady: Bool = true
|
|
||||||
/// v0.2.0: kept for source compatibility with the previous Qwen3
|
|
||||||
/// CoreML local engine. Always `false` now — there are no weights
|
|
||||||
/// for the host app to preload.
|
|
||||||
@Published public var localModelsLoaded: Bool = false
|
|
||||||
/// v0.2.1 follow-up: derived — translation is on iff a target
|
/// v0.2.1 follow-up: derived — translation is on iff a target
|
||||||
/// locale has been selected (mirrors `ProviderConfig.translationEnabled`
|
/// locale has been selected (mirrors `ProviderConfig.translationEnabled`
|
||||||
/// so the chip / pipeline read the same source of truth).
|
/// so the chip / pipeline read the same source of truth).
|
||||||
@@ -103,9 +100,6 @@ 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
|
||||||
/// v0.2.0: mirrored from App Group — kept for source compatibility.
|
|
||||||
/// Local engine always runs built-in polish; the flag is ignored.
|
|
||||||
@Published public var localModeCloudPolishEnabled: Bool = true
|
|
||||||
/// Mirrored from App Group — swaps delete / return on the bottom row.
|
/// Mirrored from App Group — swaps delete / return on the bottom row.
|
||||||
@Published public var handednessPreference: HandednessPreference = .left
|
@Published public var handednessPreference: HandednessPreference = .left
|
||||||
/// Press-and-drag pads beside the mic for four-way caret movement.
|
/// Press-and-drag pads beside the mic for four-way caret movement.
|
||||||
@@ -159,7 +153,6 @@ public final class KeyboardState: ObservableObject {
|
|||||||
public var setMode: (InputMode) -> Void = { _ in }
|
public var setMode: (InputMode) -> Void = { _ in }
|
||||||
public var setLocale: (String) -> Void = { _ in }
|
public var setLocale: (String) -> Void = { _ in }
|
||||||
public var setEngineMode: (String) -> Void = { _ in }
|
public var setEngineMode: (String) -> Void = { _ in }
|
||||||
public var setLocalASRBackend: (LocalASRBackend) -> Void = { _ in }
|
|
||||||
/// v0.2.1 follow-up: only the locale picker remains — `enabled`
|
/// v0.2.1 follow-up: only the locale picker remains — `enabled`
|
||||||
/// is derived from the locale id, so there's no separate toggle to
|
/// is derived from the locale id, so there's no separate toggle to
|
||||||
/// persist. Wired in `KeyboardViewController.installStateActions`.
|
/// persist. Wired in `KeyboardViewController.installStateActions`.
|
||||||
@@ -209,4 +202,20 @@ public final class KeyboardState: ObservableObject {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
extension KeyboardState.Phase.ErrorKind {
|
||||||
|
/// Maps a host-app Flow transcription failure into a keyboard error kind.
|
||||||
|
public static func fromFlowTranscription(_ error: FlowTranscriptionError) -> Self {
|
||||||
|
switch error.kind {
|
||||||
|
case .noSpeech:
|
||||||
|
return .noSpeechDetected
|
||||||
|
case .recognitionInterrupted:
|
||||||
|
return .recognitionInterrupted
|
||||||
|
case .audioUnavailable:
|
||||||
|
return .hostAudioUnavailable
|
||||||
|
case .asrFailed, .generic:
|
||||||
|
return .hostTranscriptionFailed(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
// This class is still imported by:
|
// This class is still imported by:
|
||||||
// - `OSGKeyboard/Views/PreviewASRController.swift` (typealias)
|
// - `OSGKeyboard/Views/PreviewASRController.swift` (typealias)
|
||||||
// - `OSGKeyboard/Views/KeyboardPreviewSheet.swift` (in-app preview)
|
// - `OSGKeyboard/Views/KeyboardPreviewSheet.swift` (in-app preview)
|
||||||
// - `OSGKeyboard/Views/DictationCaptureView.swift` (host-app fallback)
|
// - `OSGKeyboard/Views/KeyboardPreviewSheet.swift` (host-app ASR preview)
|
||||||
// - `OSGKeyboardTests/PreviewASRControllerStateTests.swift`
|
// - `OSGKeyboardTests/PreviewASRControllerStateTests.swift`
|
||||||
//
|
//
|
||||||
// Do NOT remove without updating those call sites. The earlier
|
// Do NOT remove without updating those call sites. The earlier
|
||||||
@@ -104,14 +104,7 @@ public final class LiveDictationController: ObservableObject {
|
|||||||
private var didInstallTap = false
|
private var didInstallTap = false
|
||||||
|
|
||||||
public init(asr: ASRService? = nil) {
|
public init(asr: ASRService? = nil) {
|
||||||
// Resolve through the factory so the user's `LocalASRBackend`
|
self.asr = asr ?? ASRServiceFactory.make()
|
||||||
// selection is honoured. Tests can pass a stub `asr` directly
|
|
||||||
// to bypass the factory and exercise the controller in
|
|
||||||
// isolation.
|
|
||||||
self.asr = asr ?? ASRServiceFactory.make(
|
|
||||||
engineMode: ProviderConfig.shared.engineMode,
|
|
||||||
localBackend: ProviderConfig.shared.localASRBackend
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start dictation using a persisted settings locale id (`auto`, `zh-Hans`, …).
|
/// Start dictation using a persisted settings locale id (`auto`, `zh-Hans`, …).
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// OSGLog.swift
|
||||||
|
// OSGKeyboard · Shared
|
||||||
|
//
|
||||||
|
// Unified os.Logger categories for cross-target diagnostics. Filter in
|
||||||
|
// Console.app with subsystem `com.osgkeyboard.ios`.
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
public enum OSGLog {
|
||||||
|
private static let subsystem = "com.osgkeyboard.ios"
|
||||||
|
|
||||||
|
public static let flow = Logger(subsystem: subsystem, category: "flow")
|
||||||
|
public static let clm = Logger(subsystem: subsystem, category: "clm")
|
||||||
|
public static let config = Logger(subsystem: subsystem, category: "config")
|
||||||
|
public static let asr = Logger(subsystem: subsystem, category: "asr")
|
||||||
|
public static let keyboardExt = Logger(subsystem: subsystem, category: "keyboardExt")
|
||||||
|
}
|
||||||
@@ -17,7 +17,8 @@ public enum UtteranceStreamChunker {
|
|||||||
AsyncStream { continuation in
|
AsyncStream { continuation in
|
||||||
let task = Task {
|
let task = Task {
|
||||||
var buffer: [Float] = []
|
var buffer: [Float] = []
|
||||||
buffer.reserveCapacity(config.maxChunkSamples + config.pauseExtensionSamples)
|
let initialCapacity = config.maxChunkSamples(forChunkIndex: 0) + config.pauseExtensionSamples
|
||||||
|
buffer.reserveCapacity(initialCapacity)
|
||||||
var chunkIndex = 0
|
var chunkIndex = 0
|
||||||
|
|
||||||
func emit(upTo splitEnd: Int, isLast: Bool) {
|
func emit(upTo splitEnd: Int, isLast: Bool) {
|
||||||
@@ -40,8 +41,12 @@ public enum UtteranceStreamChunker {
|
|||||||
guard !snap.samples.isEmpty else { continue }
|
guard !snap.samples.isEmpty else { continue }
|
||||||
buffer.append(contentsOf: snap.samples)
|
buffer.append(contentsOf: snap.samples)
|
||||||
|
|
||||||
while buffer.count >= config.maxChunkSamples {
|
while buffer.count >= config.maxChunkSamples(forChunkIndex: chunkIndex) {
|
||||||
let split = pauseAwareSplitIndex(in: buffer, config: config)
|
let split = pauseAwareSplitIndex(
|
||||||
|
in: buffer,
|
||||||
|
config: config,
|
||||||
|
chunkIndex: chunkIndex
|
||||||
|
)
|
||||||
emit(upTo: split, isLast: false)
|
emit(upTo: split, isLast: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,9 +71,10 @@ public enum UtteranceStreamChunker {
|
|||||||
/// Pick a split index at or after `maxChunkSamples`, preferring a pause.
|
/// Pick a split index at or after `maxChunkSamples`, preferring a pause.
|
||||||
static func pauseAwareSplitIndex(
|
static func pauseAwareSplitIndex(
|
||||||
in buffer: [Float],
|
in buffer: [Float],
|
||||||
config: FlowUtteranceChunkConfig
|
config: FlowUtteranceChunkConfig,
|
||||||
|
chunkIndex: Int = 1
|
||||||
) -> Int {
|
) -> Int {
|
||||||
let minSplit = config.maxChunkSamples
|
let minSplit = config.maxChunkSamples(forChunkIndex: chunkIndex)
|
||||||
guard buffer.count >= minSplit else { return buffer.count }
|
guard buffer.count >= minSplit else { return buffer.count }
|
||||||
|
|
||||||
let searchEnd = min(buffer.count, minSplit + config.pauseExtensionSamples)
|
let searchEnd = min(buffer.count, minSplit + config.pauseExtensionSamples)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"provider.qwen" = "Qwen (DashScope)";
|
"provider.qwen" = "Qwen (DashScope)";
|
||||||
"provider.zhipu" = "Zhipu GLM";
|
"provider.zhipu" = "Zhipu GLM";
|
||||||
"provider.moonshot" = "Moonshot";
|
"provider.moonshot" = "Moonshot";
|
||||||
|
"provider.mimo" = "Xiaomi MiMo";
|
||||||
"provider.custom" = "Custom";
|
"provider.custom" = "Custom";
|
||||||
|
|
||||||
/* LLM errors */
|
/* LLM errors */
|
||||||
@@ -79,3 +80,10 @@
|
|||||||
"dict.source.history" = "Auto-learned";
|
"dict.source.history" = "Auto-learned";
|
||||||
"dict.source.contacts" = "From Contacts";
|
"dict.source.contacts" = "From Contacts";
|
||||||
"dict.source.recentEdit" = "From recent edit";
|
"dict.source.recentEdit" = "From recent edit";
|
||||||
|
|
||||||
|
/* Keyboard UI (shared between extension + preview) */
|
||||||
|
"keyboard.tapToTalkA11y" = "Tap to talk";
|
||||||
|
"keyboard.translation.chip" = "Translate";
|
||||||
|
"keyboard.translation.offMenu" = "Don't translate";
|
||||||
|
"keyboard.translation.a11y" = "Translation";
|
||||||
|
"keyboard.translation.a11yHint" = "Toggle translation or change the target language.";
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"provider.qwen" = "通义千问";
|
"provider.qwen" = "通义千问";
|
||||||
"provider.zhipu" = "智谱 GLM";
|
"provider.zhipu" = "智谱 GLM";
|
||||||
"provider.moonshot" = "月之暗面";
|
"provider.moonshot" = "月之暗面";
|
||||||
|
"provider.mimo" = "小米 MiMo";
|
||||||
"provider.custom" = "自定义";
|
"provider.custom" = "自定义";
|
||||||
|
|
||||||
/* LLM errors */
|
/* LLM errors */
|
||||||
@@ -79,3 +80,10 @@
|
|||||||
"dict.source.history" = "自动学习";
|
"dict.source.history" = "自动学习";
|
||||||
"dict.source.contacts" = "来自通讯录";
|
"dict.source.contacts" = "来自通讯录";
|
||||||
"dict.source.recentEdit" = "来自最近编辑";
|
"dict.source.recentEdit" = "来自最近编辑";
|
||||||
|
|
||||||
|
/* 键盘 UI(扩展与预览共用) */
|
||||||
|
"keyboard.tapToTalkA11y" = "点击说话";
|
||||||
|
"keyboard.translation.chip" = "翻译";
|
||||||
|
"keyboard.translation.offMenu" = "不翻译";
|
||||||
|
"keyboard.translation.a11y" = "翻译";
|
||||||
|
"keyboard.translation.a11yHint" = "切换翻译或更改目标语言。";
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// AppGroupConfigurationTests.swift
|
||||||
|
// OSGKeyboardTests
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import OSGKeyboardShared
|
||||||
|
|
||||||
|
final class AppGroupConfigurationTests: XCTestCase {
|
||||||
|
|
||||||
|
private func makeDefaults() -> UserDefaults {
|
||||||
|
let suite = "group.com.osgkeyboard.shared.tests.config.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
|
defaults.removePersistentDomain(forName: suite)
|
||||||
|
return defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLoadDefaultsWhenSuiteIsEmpty() {
|
||||||
|
let defaults = makeDefaults()
|
||||||
|
let config = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||||
|
|
||||||
|
XCTAssertEqual(config.providerId, "openai")
|
||||||
|
XCTAssertEqual(config.modeId, "polish")
|
||||||
|
XCTAssertEqual(config.localeId, "auto")
|
||||||
|
XCTAssertEqual(config.engineMode, "cloud")
|
||||||
|
XCTAssertFalse(config.hasCompletedOnboarding)
|
||||||
|
XCTAssertEqual(config.onboardingPage, 0)
|
||||||
|
XCTAssertFalse(config.hasAcknowledgedCloudSharing)
|
||||||
|
XCTAssertEqual(config.translationTargetLocaleId, TranslationLanguageCatalog.offLocaleId)
|
||||||
|
XCTAssertFalse(config.translationEnabled)
|
||||||
|
XCTAssertEqual(config.handednessPreference, .left)
|
||||||
|
XCTAssertTrue(config.cursorDragNavigationEnabled)
|
||||||
|
XCTAssertEqual(config.polishIntensity, .default)
|
||||||
|
XCTAssertTrue(config.personalDictionary.entries.isEmpty)
|
||||||
|
XCTAssertTrue(config.flowSkipAppSwitch)
|
||||||
|
XCTAssertEqual(config.flowInactivityDuration, .twelveHours)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSaveAndLoadRoundTrip() {
|
||||||
|
let defaults = makeDefaults()
|
||||||
|
var config = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||||
|
config.providerId = "anthropic"
|
||||||
|
config.baseURL = "https://example.com/v1"
|
||||||
|
config.model = "claude-test"
|
||||||
|
config.modeId = "polish"
|
||||||
|
config.localeId = "zh-Hans"
|
||||||
|
config.engineMode = "local"
|
||||||
|
config.hasCompletedOnboarding = true
|
||||||
|
config.onboardingPage = 2
|
||||||
|
config.hasAcknowledgedCloudSharing = true
|
||||||
|
config.uiLanguage = .chinese
|
||||||
|
config.translationTargetLocaleId = "en"
|
||||||
|
config.handednessPreference = .right
|
||||||
|
config.cursorDragNavigationEnabled = false
|
||||||
|
config.polishIntensity = .light
|
||||||
|
config.flowSkipAppSwitch = false
|
||||||
|
config.flowInactivityDuration = .thirtyMinutes
|
||||||
|
config.save(to: defaults)
|
||||||
|
|
||||||
|
let loaded = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||||
|
XCTAssertEqual(loaded.providerId, "anthropic")
|
||||||
|
XCTAssertEqual(loaded.baseURL, "https://example.com/v1")
|
||||||
|
XCTAssertEqual(loaded.model, "claude-test")
|
||||||
|
XCTAssertEqual(loaded.localeId, "zh-Hans")
|
||||||
|
XCTAssertEqual(loaded.engineMode, "local")
|
||||||
|
XCTAssertTrue(loaded.hasCompletedOnboarding)
|
||||||
|
XCTAssertEqual(loaded.onboardingPage, 2)
|
||||||
|
XCTAssertTrue(loaded.hasAcknowledgedCloudSharing)
|
||||||
|
XCTAssertEqual(loaded.uiLanguage, .chinese)
|
||||||
|
XCTAssertEqual(loaded.translationTargetLocaleId, "en")
|
||||||
|
XCTAssertTrue(loaded.translationEnabled)
|
||||||
|
XCTAssertEqual(loaded.handednessPreference, .right)
|
||||||
|
XCTAssertFalse(loaded.cursorDragNavigationEnabled)
|
||||||
|
XCTAssertEqual(loaded.polishIntensity, .light)
|
||||||
|
XCTAssertFalse(loaded.flowSkipAppSwitch)
|
||||||
|
XCTAssertEqual(loaded.flowInactivityDuration, .thirtyMinutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTranslationEnabledDerivedFromTargetLocale() {
|
||||||
|
let defaults = makeDefaults()
|
||||||
|
var config = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||||
|
XCTAssertFalse(config.translationEnabled)
|
||||||
|
|
||||||
|
config.translationTargetLocaleId = "ja"
|
||||||
|
XCTAssertTrue(config.translationEnabled)
|
||||||
|
|
||||||
|
config.translationTargetLocaleId = TranslationLanguageCatalog.offLocaleId
|
||||||
|
XCTAssertFalse(config.translationEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCloudDeepSeekProviderMigratesToOpenAI() {
|
||||||
|
let defaults = makeDefaults()
|
||||||
|
defaults.set("deepseek", forKey: AppGroupConfiguration.Keys.providerId)
|
||||||
|
defaults.set("cloud", forKey: AppGroupConfiguration.Keys.engineMode)
|
||||||
|
|
||||||
|
let config = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||||
|
XCTAssertEqual(config.providerId, "openai")
|
||||||
|
XCTAssertEqual(defaults.string(forKey: AppGroupConfiguration.Keys.providerId), "openai")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPolishIntensityLegacyOffMigratesToMedium() {
|
||||||
|
let defaults = makeDefaults()
|
||||||
|
defaults.set(PolishIntensity.legacyOffRawValue, forKey: AppGroupConfiguration.Keys.polishIntensity)
|
||||||
|
|
||||||
|
let config = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||||
|
XCTAssertEqual(config.polishIntensity, .medium)
|
||||||
|
XCTAssertEqual(defaults.string(forKey: AppGroupConfiguration.Keys.polishIntensity), PolishIntensity.medium.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLoadFromNilUsesAppGroupWhenAvailable() {
|
||||||
|
if AppGroup.defaultsIfAvailable != nil {
|
||||||
|
XCTAssertNotNil(AppGroupConfiguration.load(from: nil))
|
||||||
|
} else {
|
||||||
|
XCTAssertNil(AppGroupConfiguration.load(from: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,10 +47,11 @@ final class ChunkedUtterancePipelineTests: XCTestCase {
|
|||||||
continuation.yield(AudioBufferSnapshot(samples: [Float](repeating: 0.1, count: 80), sampleRate: 1_000))
|
continuation.yield(AudioBufferSnapshot(samples: [Float](repeating: 0.1, count: 80), sampleRate: 1_000))
|
||||||
continuation.finish()
|
continuation.finish()
|
||||||
|
|
||||||
var partials: [String] = []
|
let partialsLock = OSAllocatedUnfairLock(initialState: [String]())
|
||||||
let outcome = await pipeline.transcribe(stream: stream) { partial in
|
let outcome = await pipeline.transcribe(stream: stream) { partial in
|
||||||
partials.append(partial)
|
partialsLock.withLock { $0.append(partial) }
|
||||||
}
|
}
|
||||||
|
let partials = partialsLock.withLock { $0 }
|
||||||
|
|
||||||
guard case .success(let success) = outcome else {
|
guard case .success(let success) = outcome else {
|
||||||
return XCTFail("expected success, got \(outcome)")
|
return XCTFail("expected success, got \(outcome)")
|
||||||
@@ -89,8 +90,7 @@ final class ChunkedUtterancePipelineTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private struct FailingSecondChunkASR: ASRService, @unchecked Sendable {
|
private struct FailingSecondChunkASR: ASRService, @unchecked Sendable {
|
||||||
private let lock = OSAllocatedUnfairLock()
|
private let callIndex = OSAllocatedUnfairLock(initialState: 0)
|
||||||
private var index = 0
|
|
||||||
|
|
||||||
func transcribe(
|
func transcribe(
|
||||||
stream: AsyncStream<AudioBufferSnapshot>,
|
stream: AsyncStream<AudioBufferSnapshot>,
|
||||||
@@ -103,9 +103,10 @@ private struct FailingSecondChunkASR: ASRService, @unchecked Sendable {
|
|||||||
|
|
||||||
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
|
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
|
||||||
_ = locale
|
_ = locale
|
||||||
let current = lock.withLock {
|
let current = callIndex.withLock { state in
|
||||||
defer { index += 1 }
|
let value = state
|
||||||
return index
|
state += 1
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
if current == 1 {
|
if current == 1 {
|
||||||
return .failure("simulated chunk error")
|
return .failure("simulated chunk error")
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
// DictationBridgeTests.swift
|
|
||||||
// OSGKeyboardTests
|
|
||||||
|
|
||||||
import XCTest
|
|
||||||
@testable import OSGKeyboardShared
|
|
||||||
|
|
||||||
final class DictationBridgeTests: XCTestCase {
|
|
||||||
|
|
||||||
private func makeDefaults() -> UserDefaults {
|
|
||||||
let suite = "group.com.osgkeyboard.shared.tests.dictation.\(UUID().uuidString)"
|
|
||||||
let defaults = UserDefaults(suiteName: suite)!
|
|
||||||
defaults.removePersistentDomain(forName: suite)
|
|
||||||
return defaults
|
|
||||||
}
|
|
||||||
|
|
||||||
func testStoreAndConsumeTranscript() {
|
|
||||||
let defaults = makeDefaults()
|
|
||||||
|
|
||||||
DictationBridge.storePendingTranscript(" hello ", defaults: defaults)
|
|
||||||
let consumed = DictationBridge.consumePendingTranscript(defaults: defaults)
|
|
||||||
|
|
||||||
XCTAssertEqual(consumed, "hello")
|
|
||||||
XCTAssertNil(DictationBridge.consumePendingTranscript(defaults: defaults))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testConsumeIgnoresExpiredTranscript() {
|
|
||||||
let defaults = makeDefaults()
|
|
||||||
DictationBridge.storePendingTranscript("stale", defaults: defaults)
|
|
||||||
// maxAge = 1ms, then delay to force expiry
|
|
||||||
usleep(2_000)
|
|
||||||
let consumed = DictationBridge.consumePendingTranscript(maxAge: 0.001, defaults: defaults)
|
|
||||||
XCTAssertNil(consumed)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testStatusLifecycle() {
|
|
||||||
let defaults = makeDefaults()
|
|
||||||
|
|
||||||
DictationBridge.markRequested(defaults: defaults)
|
|
||||||
XCTAssertEqual(DictationBridge.currentStatus(defaults: defaults).status, .requested)
|
|
||||||
|
|
||||||
DictationBridge.setStatus(.recording, defaults: defaults)
|
|
||||||
XCTAssertEqual(DictationBridge.currentStatus(defaults: defaults).status, .recording)
|
|
||||||
|
|
||||||
DictationBridge.storePendingTranscript("ok", defaults: defaults)
|
|
||||||
XCTAssertEqual(DictationBridge.currentStatus(defaults: defaults).status, .done)
|
|
||||||
|
|
||||||
_ = DictationBridge.consumePendingTranscript(defaults: defaults)
|
|
||||||
XCTAssertEqual(DictationBridge.currentStatus(defaults: defaults).status, .idle)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testStatusMessageAndTimestamp() {
|
|
||||||
let defaults = makeDefaults()
|
|
||||||
DictationBridge.setStatus(.error, message: "fail", defaults: defaults)
|
|
||||||
let snapshot = DictationBridge.currentStatus(defaults: defaults)
|
|
||||||
XCTAssertEqual(snapshot.status, .error)
|
|
||||||
XCTAssertEqual(snapshot.message, "fail")
|
|
||||||
XCTAssertGreaterThan(snapshot.updatedAt, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -84,6 +84,27 @@ final class FlowSessionBridgeTests: XCTestCase {
|
|||||||
XCTAssertNil(FlowSessionBridge.remainingSessionDuration(defaults: defaults))
|
XCTAssertNil(FlowSessionBridge.remainingSessionDuration(defaults: defaults))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testConsumeTranscriptionErrorIncludesKind() {
|
||||||
|
let defaults = makeDefaults()
|
||||||
|
FlowSessionBridge.storeTranscriptionError(
|
||||||
|
"no speech",
|
||||||
|
kind: .noSpeech,
|
||||||
|
defaults: defaults
|
||||||
|
)
|
||||||
|
let error = FlowSessionBridge.consumeTranscriptionError(defaults: defaults)
|
||||||
|
XCTAssertEqual(error?.message, "no speech")
|
||||||
|
XCTAssertEqual(error?.kind, .noSpeech)
|
||||||
|
XCTAssertNil(FlowSessionBridge.consumeTranscriptionError(defaults: defaults))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTranscriptionPartialRoundTrip() {
|
||||||
|
let defaults = makeDefaults()
|
||||||
|
FlowSessionBridge.storeTranscriptionPartial("你好世界", defaults: defaults)
|
||||||
|
XCTAssertEqual(FlowSessionBridge.transcriptionPartial(defaults: defaults), "你好世界")
|
||||||
|
FlowSessionBridge.storeTranscriptionResult("final", defaults: defaults)
|
||||||
|
XCTAssertNil(FlowSessionBridge.transcriptionPartial(defaults: defaults))
|
||||||
|
}
|
||||||
|
|
||||||
func testDarwinNotificationPostsWithoutCrashing() {
|
func testDarwinNotificationPostsWithoutCrashing() {
|
||||||
FlowSessionDarwin.postSessionChanged()
|
FlowSessionDarwin.postSessionChanged()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// FlowSessionPolicyTests.swift
|
||||||
|
// OSGKeyboardTests
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import OSGKeyboardShared
|
||||||
|
|
||||||
|
final class FlowSessionPolicyTests: XCTestCase {
|
||||||
|
private func makeDefaults() -> UserDefaults {
|
||||||
|
let suite = "group.com.osgkeyboard.shared.tests.policy.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
|
defaults.removePersistentDomain(forName: suite)
|
||||||
|
return defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSkipAppSwitchDefaultsToTrue() {
|
||||||
|
let defaults = makeDefaults()
|
||||||
|
XCTAssertTrue(FlowSessionPolicy.skipAppSwitch(defaults: defaults))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInactivityDurationDefaultsToTwelveHours() {
|
||||||
|
let defaults = makeDefaults()
|
||||||
|
XCTAssertEqual(FlowSessionPolicy.inactivityDuration(defaults: defaults), .twelveHours)
|
||||||
|
XCTAssertEqual(FlowSessionPolicy.sessionDuration(defaults: defaults), 12 * 60 * 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTouchLastActivityExtendsExpiry() {
|
||||||
|
let defaults = makeDefaults()
|
||||||
|
defaults.set(FlowInactivityDuration.tenMinutes.rawValue, forKey: AppGroupConfiguration.Keys.flowInactivityDuration)
|
||||||
|
FlowSessionBridge.markSessionActive(defaults: defaults)
|
||||||
|
|
||||||
|
let staleExpiry = Date().timeIntervalSince1970 + 30
|
||||||
|
defaults.set(staleExpiry, forKey: FlowSessionKeys.flowSessionExpires)
|
||||||
|
FlowSessionBridge.touchLastActivity(defaults: defaults)
|
||||||
|
|
||||||
|
let refreshed = FlowSessionBridge.sessionExpiresAt(defaults: defaults) ?? 0
|
||||||
|
XCTAssertGreaterThan(refreshed, staleExpiry)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPendingHostBundleIdRoundTrip() {
|
||||||
|
let defaults = makeDefaults()
|
||||||
|
FlowSessionBridge.setPendingHostBundleId("com.tencent.xin", defaults: defaults)
|
||||||
|
XCTAssertEqual(FlowSessionBridge.pendingHostBundleId(defaults: defaults), "com.tencent.xin")
|
||||||
|
FlowSessionBridge.clearPendingHostBundleId(defaults: defaults)
|
||||||
|
XCTAssertNil(FlowSessionBridge.pendingHostBundleId(defaults: defaults))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// HostAppURLRegistryTests.swift
|
||||||
|
// OSGKeyboardTests
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import OSGKeyboardShared
|
||||||
|
|
||||||
|
final class HostAppURLRegistryTests: XCTestCase {
|
||||||
|
func testWeChatLookup() {
|
||||||
|
let entry = HostAppURLRegistry.lookup(bundleId: "com.tencent.xin")
|
||||||
|
XCTAssertEqual(entry?.returnURLString, "weixin://")
|
||||||
|
XCTAssertEqual(entry?.displayNameKey, "hostApp.wechat")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnknownBundleReturnsNil() {
|
||||||
|
XCTAssertNil(HostAppURLRegistry.lookup(bundleId: "com.apple.MobileSMS"))
|
||||||
|
XCTAssertNil(HostAppURLRegistry.lookup(bundleId: nil))
|
||||||
|
XCTAssertNil(HostAppURLRegistry.lookup(bundleId: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testQuerySchemesIncludesWeChat() {
|
||||||
|
XCTAssertTrue(HostAppURLRegistry.querySchemes.contains("weixin"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEntriesHaveUniqueBundleIds() {
|
||||||
|
let ids = HostAppURLRegistry.entries.map(\.bundleId)
|
||||||
|
XCTAssertEqual(Set(ids).count, ids.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,10 +122,7 @@ final class IntelligentPolishTests: XCTestCase {
|
|||||||
|
|
||||||
func testPolishServiceMissingAPIKeyThrows() async {
|
func testPolishServiceMissingAPIKeyThrows() async {
|
||||||
store.setEngineMode("cloud")
|
store.setEngineMode("cloud")
|
||||||
let service = PolishingService(
|
let service = PolishingService(store: store)
|
||||||
store: store,
|
|
||||||
client: EchoLLMClient()
|
|
||||||
)
|
|
||||||
do {
|
do {
|
||||||
_ = try await service.polish("hello world", context: PolishContext(intensity: .medium))
|
_ = try await service.polish("hello world", context: PolishContext(intensity: .medium))
|
||||||
XCTFail("Expected missingAPIKey")
|
XCTFail("Expected missingAPIKey")
|
||||||
@@ -218,7 +215,10 @@ final class IntelligentPolishTests: XCTestCase {
|
|||||||
store.setEngineMode("local")
|
store.setEngineMode("local")
|
||||||
let captured = CapturingLLMClient()
|
let captured = CapturingLLMClient()
|
||||||
let service = PolishingService(store: store, client: captured)
|
let service = PolishingService(store: store, client: captured)
|
||||||
_ = try await service.polish("hello", context: PolishContext(intensity: .medium))
|
_ = try await service.polish(
|
||||||
|
"今天我们部署 k8s 集群",
|
||||||
|
context: PolishContext(intensity: .medium)
|
||||||
|
)
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(
|
||||||
captured.lastPrompt.contains("全局输出契约"),
|
captured.lastPrompt.contains("全局输出契约"),
|
||||||
"Local engine should get the Chinese prompt via DeepSeek. Got prefix: \(captured.lastPrompt.prefix(80))"
|
"Local engine should get the Chinese prompt via DeepSeek. Got prefix: \(captured.lastPrompt.prefix(80))"
|
||||||
|
|||||||
@@ -39,30 +39,36 @@ final class KeyboardOnboardingOverlayTests: XCTestCase {
|
|||||||
|
|
||||||
func testOnboardingFlagsRoundTrip() {
|
func testOnboardingFlagsRoundTrip() {
|
||||||
store.onboardingPage = 3
|
store.onboardingPage = 3
|
||||||
store.hasCompletedOnboarding = true
|
|
||||||
XCTAssertEqual(store.onboardingPage, 3)
|
XCTAssertEqual(store.onboardingPage, 3)
|
||||||
|
store.hasCompletedOnboarding = true
|
||||||
XCTAssertTrue(store.hasCompletedOnboarding)
|
XCTAssertTrue(store.hasCompletedOnboarding)
|
||||||
|
// Completing onboarding clears the in-progress page index.
|
||||||
|
XCTAssertEqual(store.onboardingPage, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testOnboardingFlagsSurviveReconstruct() {
|
func testOnboardingFlagsSurviveReconstruct() {
|
||||||
store.onboardingPage = 4
|
store.onboardingPage = 4
|
||||||
store.hasCompletedOnboarding = true
|
|
||||||
|
|
||||||
// Simulate the keyboard extension being torn down and rebuilt
|
// Simulate the keyboard extension being torn down and rebuilt
|
||||||
// (which is what happens on every `viewDidLoad` cycle).
|
// (which is what happens on every `viewDidLoad` cycle).
|
||||||
let store2 = AppGroupStore(defaults: defaults)
|
var store2 = AppGroupStore(defaults: defaults)
|
||||||
XCTAssertEqual(store2.onboardingPage, 4)
|
XCTAssertEqual(store2.onboardingPage, 4)
|
||||||
XCTAssertTrue(store2.hasCompletedOnboarding)
|
|
||||||
|
store2.hasCompletedOnboarding = true
|
||||||
|
let store3 = AppGroupStore(defaults: defaults)
|
||||||
|
XCTAssertTrue(store3.hasCompletedOnboarding)
|
||||||
|
XCTAssertEqual(store3.onboardingPage, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - App context detection round-trip
|
// MARK: - App context detection round-trip
|
||||||
|
|
||||||
func testDetectedAppContextRoundTrip() {
|
func testDetectedAppContextRoundTrip() throws {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
store.setDetectedAppContext(.code, at: now)
|
store.setDetectedAppContext(.code, at: now)
|
||||||
let result = store.detectedAppContext
|
let result = store.detectedAppContext
|
||||||
XCTAssertEqual(result?.context, .code)
|
XCTAssertEqual(result?.context, .code)
|
||||||
XCTAssertEqual(result?.observedAt.timeIntervalSinceReferenceDate,
|
let observedAt = try XCTUnwrap(result?.observedAt)
|
||||||
|
XCTAssertEqual(observedAt.timeIntervalSinceReferenceDate,
|
||||||
now.timeIntervalSinceReferenceDate,
|
now.timeIntervalSinceReferenceDate,
|
||||||
accuracy: 0.001)
|
accuracy: 0.001)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,13 +237,13 @@ final class LLMClientTests: XCTestCase {
|
|||||||
|
|
||||||
/// Cross-process App Group contract: what `ProviderConfig` writes must
|
/// Cross-process App Group contract: what `ProviderConfig` writes must
|
||||||
/// be readable through `AppGroupStore` on the same suite.
|
/// be readable through `AppGroupStore` on the same suite.
|
||||||
func testAppGroupCrossProcessAndOffModeShortCircuit() async {
|
func testAppGroupCrossProcessLegacyOffModeMigratesToPolish() async {
|
||||||
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
|
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
|
||||||
let defaults = UserDefaults(suiteName: suiteName)!
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
defaults.removePersistentDomain(forName: suiteName)
|
defaults.removePersistentDomain(forName: suiteName)
|
||||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||||
|
|
||||||
// Writer side: ProviderConfig (main App) writes API key + mode = off.
|
// Writer side: ProviderConfig (main App) writes API key + legacy off mode.
|
||||||
let config = ProviderConfig(defaults: defaults)
|
let config = ProviderConfig(defaults: defaults)
|
||||||
config.apiKey = "sk-test-1234"
|
config.apiKey = "sk-test-1234"
|
||||||
config.model = "gpt-4o-mini"
|
config.model = "gpt-4o-mini"
|
||||||
@@ -254,7 +254,7 @@ final class LLMClientTests: XCTestCase {
|
|||||||
// same suite.
|
// same suite.
|
||||||
let store = AppGroupStore(defaults: defaults)
|
let store = AppGroupStore(defaults: defaults)
|
||||||
XCTAssertEqual(store.apiKey, "sk-test-1234", "API key did not survive the cross-process boundary")
|
XCTAssertEqual(store.apiKey, "sk-test-1234", "API key did not survive the cross-process boundary")
|
||||||
XCTAssertEqual(store.modeId, "off")
|
XCTAssertEqual(store.modeId, "polish", "legacy off mode migrates to polish")
|
||||||
XCTAssertEqual(store.model, "gpt-4o-mini")
|
XCTAssertEqual(store.model, "gpt-4o-mini")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,20 +317,19 @@ final class LLMClientTests: XCTestCase {
|
|||||||
XCTAssertEqual(calls, 1, "cloud engine must polish even with legacy modeId=off")
|
XCTAssertEqual(calls, 1, "cloud engine must polish even with legacy modeId=off")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Local engine is ASR-only and never calls the cloud `LLMClient`.
|
/// Local engine always runs the built-in DeepSeek polish step.
|
||||||
func testPolisherReturnsRawWhenEngineLocal() async throws {
|
func testPolisherInvokesLLMWhenEngineLocal() async throws {
|
||||||
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
|
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
|
||||||
let defaults = UserDefaults(suiteName: suiteName)!
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
defaults.removePersistentDomain(forName: suiteName)
|
defaults.removePersistentDomain(forName: suiteName)
|
||||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||||
|
|
||||||
defaults.set("local", forKey: "config.engineMode")
|
defaults.set("local", forKey: "config.engineMode")
|
||||||
defaults.set("off", forKey: "config.modeId")
|
defaults.set("polish", forKey: "config.modeId")
|
||||||
|
|
||||||
let counter = CallCounter()
|
let counter = CallCounter()
|
||||||
let countingClient = CountingLLMClient(counter: counter) { _, _ in
|
let countingClient = CountingLLMClient(counter: counter) { raw, _ in
|
||||||
XCTFail("cloud LLMClient must not run under local engine")
|
"POLISHED: \(raw)"
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let store = AppGroupStore(defaults: defaults)
|
let store = AppGroupStore(defaults: defaults)
|
||||||
@@ -340,10 +339,10 @@ final class LLMClientTests: XCTestCase {
|
|||||||
timeout: 1
|
timeout: 1
|
||||||
)
|
)
|
||||||
|
|
||||||
let result = try await polisher.polish(" hello ")
|
let result = try await polisher.polish("hello world")
|
||||||
XCTAssertEqual(result, "hello")
|
XCTAssertEqual(result, "POLISHED: hello world")
|
||||||
let calls = await counter.value()
|
let calls = await counter.value()
|
||||||
XCTAssertEqual(calls, 0)
|
XCTAssertEqual(calls, 1, "local engine must always invoke the polish LLM step")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Local engine pins DeepSeek — cloud-provider URL/model in App Group
|
/// Local engine pins DeepSeek — cloud-provider URL/model in App Group
|
||||||
|
|||||||
@@ -24,6 +24,30 @@ final class UtteranceStreamChunkerTests: XCTestCase {
|
|||||||
XCTAssertLessThanOrEqual(split, config.maxChunkSamples + config.pauseExtensionSamples)
|
XCTAssertLessThanOrEqual(split, config.maxChunkSamples + config.pauseExtensionSamples)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testFirstChunkUsesShorterWindow() async {
|
||||||
|
let config = FlowUtteranceChunkConfig(
|
||||||
|
firstChunkDurationSeconds: 0.5,
|
||||||
|
subsequentChunkDurationSeconds: 1.0,
|
||||||
|
overlapDurationSeconds: 0,
|
||||||
|
pauseExtensionMaxSeconds: 0,
|
||||||
|
pauseRMSThreshold: 0.02,
|
||||||
|
sampleRate: 1_000
|
||||||
|
)
|
||||||
|
let firstChunkSamples = config.maxChunkSamples(forChunkIndex: 0) + 50
|
||||||
|
let samples = [Float](repeating: 0.05, count: firstChunkSamples)
|
||||||
|
let (stream, continuation) = AsyncStream<AudioBufferSnapshot>.makeStream()
|
||||||
|
continuation.yield(AudioBufferSnapshot(samples: samples, sampleRate: Double(config.sampleRate)))
|
||||||
|
continuation.finish()
|
||||||
|
|
||||||
|
var received: [UtteranceAudioChunk] = []
|
||||||
|
for await chunk in UtteranceStreamChunker.chunks(from: stream, config: config) {
|
||||||
|
received.append(chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertGreaterThanOrEqual(received.count, 2)
|
||||||
|
XCTAssertLessThanOrEqual(received[0].samples.count, config.maxChunkSamples(forChunkIndex: 0) + 50)
|
||||||
|
}
|
||||||
|
|
||||||
func testChunksEmitMultipleSegmentsForLongStream() async {
|
func testChunksEmitMultipleSegmentsForLongStream() async {
|
||||||
let sampleCount = config.maxChunkSamples * 2 + 100
|
let sampleCount = config.maxChunkSamples * 2 + 100
|
||||||
let samples = [Float](repeating: 0.05, count: sampleCount)
|
let samples = [Float](repeating: 0.05, count: sampleCount)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
OSGKeyboard is a free, source-available alternative to commercial voice-input tools. It runs as a **Custom Keyboard Extension** on iOS, so you can use it in **any app** — Messages, Notes, Mail, WeChat, ChatGPT, Claude, Cursor, you name it.
|
OSGKeyboard is a free, source-available alternative to commercial voice-input tools. It runs as a **Custom Keyboard Extension** on iOS, so you can use it in **any app** — Messages, Notes, Mail, WeChat, ChatGPT, Claude, Cursor, you name it.
|
||||||
|
|
||||||
1. Tap the mic to start recording
|
1. Tap the mic to start recording
|
||||||
2. Speak naturally (up to 60 seconds per take)
|
2. Speak naturally (up to 3.5 minutes / 210 seconds per take)
|
||||||
3. Tap again to stop — the AI polishes your words into clean text and inserts at the cursor
|
3. Tap again to stop — the AI polishes your words into clean text and inserts at the cursor
|
||||||
|
|
||||||
Audio is transcribed **on-device** by Apple's `SpeechAnalyzer` + `DictationTranscriber` (iOS 26+). Only the **polished transcript** is sent to your chosen cloud LLM. **No audio ever leaves your phone.**
|
Audio is transcribed **on-device** by Apple's `SpeechAnalyzer` + `DictationTranscriber` (iOS 26+). Only the **polished transcript** is sent to your chosen cloud LLM. **No audio ever leaves your phone.**
|
||||||
@@ -29,7 +29,7 @@ Under the hood, OSGKeyboard uses a **Flow session model**: a long-lived audio se
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 🎙 **Tap-to-toggle recording** with a Typeless-style circular mic button, 60-second per-take cap with live countdown
|
- 🎙 **Tap-to-toggle recording** with a Typeless-style circular mic button, 3.5-minute (210s) per-take cap with live countdown
|
||||||
- 🧠 **On-device ASR** (`SpeechAnalyzer` + `DictationTranscriber`, iOS 26+)
|
- 🧠 **On-device ASR** (`SpeechAnalyzer` + `DictationTranscriber`, iOS 26+)
|
||||||
- ✍️ **AI polishing** — adds structure, punctuation, fixes grammar, optionally produces lists
|
- ✍️ **AI polishing** — adds structure, punctuation, fixes grammar, optionally produces lists
|
||||||
- 🧩 **Local + cloud polish toggle** — local engine is ASR-only by default; opt into a post-ASR cloud polish step (DeepSeek by default) when the iOS speech recognition isn't strong enough for your environment (noisy far-field audio, strong accents, etc.)
|
- 🧩 **Local + cloud polish toggle** — local engine is ASR-only by default; opt into a post-ASR cloud polish step (DeepSeek by default) when the iOS speech recognition isn't strong enough for your environment (noisy far-field audio, strong accents, etc.)
|
||||||
@@ -168,7 +168,8 @@ To set it as the new default for first-time users, also bump the `defaultProvide
|
|||||||
- **~60 MB memory cap** for the keyboard extension (iOS sandbox). The Flow session is hosted in the main app, so audio buffers and ASR models live there, not in the extension.
|
- **~60 MB memory cap** for the keyboard extension (iOS sandbox). The Flow session is hosted in the main app, so audio buffers and ASR models live there, not in the extension.
|
||||||
- **"Allow Full Access" required.** Without it, the keyboard can't reach the microphone or make network requests for cloud polish.
|
- **"Allow Full Access" required.** Without it, the keyboard can't reach the microphone or make network requests for cloud polish.
|
||||||
- **Password fields and some `WKWebView` textareas** are blocked by iOS itself — not something we can work around.
|
- **Password fields and some `WKWebView` textareas** are blocked by iOS itself — not something we can work around.
|
||||||
- **60-second per-take cap.** A long take is automatically stopped and dispatched for transcription; a new take can be started immediately.
|
- **3.5-minute (210s) per-take cap.** A long take is automatically stopped and dispatched for transcription; a new take can be started immediately.
|
||||||
|
- **Force-quitting the host app does not resurrect the old session.** The Live Activity is cleared immediately; the next time you open the app (with permissions granted) a fresh voice session starts automatically.
|
||||||
- **3-minute per-utterance ASR cap.** If you exceed it, the pipeline gracefully splits into multiple stitched chunks.
|
- **3-minute per-utterance ASR cap.** If you exceed it, the pipeline gracefully splits into multiple stitched chunks.
|
||||||
- **No on-device LLM polish.** The local engine is ASR-only; "AI polish" is always cloud-based and configurable. On-device model support was explored in v0.2.0 and rolled back in v0.2.1 to keep the dependency surface at zero SPM packages.
|
- **No on-device LLM polish.** The local engine is ASR-only; "AI polish" is always cloud-based and configurable. On-device model support was explored in v0.2.0 and rolled back in v0.2.1 to keep the dependency surface at zero SPM packages.
|
||||||
- **URL scheme `osgkeyboard://`** can be opened by any app on the device. We don't trust it for anything beyond "wake the host app and (re)start the Flow session"; it never carries your API key or other secrets.
|
- **URL scheme `osgkeyboard://`** can be opened by any app on the device. We don't trust it for anything beyond "wake the host app and (re)start the Flow session"; it never carries your API key or other secrets.
|
||||||
|
|||||||
+4
-3
@@ -17,7 +17,7 @@
|
|||||||
OSGKeyboard 是商业语音输入工具的免费、源码可见替代方案。它以 **iOS 自定义键盘扩展** 的形式运行,所以你可以在 **任何 App** 里使用 —— 微信、备忘录、邮件、ChatGPT、Claude、Cursor,无所不能。
|
OSGKeyboard 是商业语音输入工具的免费、源码可见替代方案。它以 **iOS 自定义键盘扩展** 的形式运行,所以你可以在 **任何 App** 里使用 —— 微信、备忘录、邮件、ChatGPT、Claude、Cursor,无所不能。
|
||||||
|
|
||||||
1. 按下麦克风键开始录音
|
1. 按下麦克风键开始录音
|
||||||
2. 自由说话(单次上限 60 秒)
|
2. 自由说话(单次上限 3.5 分钟 / 210 秒)
|
||||||
3. 再按一下结束 —— AI 自动整理成干净的文字并插入光标
|
3. 再按一下结束 —— AI 自动整理成干净的文字并插入光标
|
||||||
|
|
||||||
**音频始终在设备本地转写**(iOS 26+ 的 `SpeechAnalyzer` + `DictationTranscriber`),**只有润色后的文本** 会发到你选择的云端 LLM。**音频永不离开你的手机。**
|
**音频始终在设备本地转写**(iOS 26+ 的 `SpeechAnalyzer` + `DictationTranscriber`),**只有润色后的文本** 会发到你选择的云端 LLM。**音频永不离开你的手机。**
|
||||||
@@ -28,7 +28,7 @@ OSGKeyboard 是商业语音输入工具的免费、源码可见替代方案。
|
|||||||
|
|
||||||
## 特性
|
## 特性
|
||||||
|
|
||||||
- 🎙 **点按录音** —— Typeless 风格的圆形麦克风按钮,单次上限 60 秒并实时倒计时
|
- 🎙 **点按录音** —— Typeless 风格的圆形麦克风按钮,单次上限 3.5 分钟(210 秒)并实时倒计时
|
||||||
- 🧠 **端侧 ASR**(iOS 26+ `SpeechAnalyzer` + `DictationTranscriber`)
|
- 🧠 **端侧 ASR**(iOS 26+ `SpeechAnalyzer` + `DictationTranscriber`)
|
||||||
- ✍️ **AI 润色** —— 自动加结构、补标点、修正语法、可生成列表
|
- ✍️ **AI 润色** —— 自动加结构、补标点、修正语法、可生成列表
|
||||||
- 🧩 **本地 + 云端润色开关** —— 本地模式默认仅在设备上识别;若 iOS 语音识别效果不理想(远场、噪声、方言),可开启「识别后云端润色」,默认走 DeepSeek
|
- 🧩 **本地 + 云端润色开关** —— 本地模式默认仅在设备上识别;若 iOS 语音识别效果不理想(远场、噪声、方言),可开启「识别后云端润色」,默认走 DeepSeek
|
||||||
@@ -165,7 +165,8 @@ LLMProvider(
|
|||||||
- **键盘扩展约 60 MB 内存上限**(iOS 沙盒)。Flow 会话由主 App 承载,音频缓冲与 ASR 模型都在主 App 侧,不占用扩展内存。
|
- **键盘扩展约 60 MB 内存上限**(iOS 沙盒)。Flow 会话由主 App 承载,音频缓冲与 ASR 模型都在主 App 侧,不占用扩展内存。
|
||||||
- **必须「允许完全访问」**。否则键盘无法使用麦克风,也无法发起云端润色请求。
|
- **必须「允许完全访问」**。否则键盘无法使用麦克风,也无法发起云端润色请求。
|
||||||
- **密码框与部分 `WKWebView` 输入框不可用**(iOS 系统限制,无法绕过)。
|
- **密码框与部分 `WKWebView` 输入框不可用**(iOS 系统限制,无法绕过)。
|
||||||
- **单次录音上限 60 秒**。到点自动停止并提交识别,下次可立即开始新的录音。
|
- **单次录音上限 3.5 分钟(210 秒)**。到点自动停止并提交识别,下次可立即开始新的录音。
|
||||||
|
- **杀掉主 App 后不复活旧会话**。灵动岛会立即清理;下次回到主 App 时(权限齐全)自动开启新的语音会话。
|
||||||
- **单次 utterance ASR 上限 3 分钟**。超出后会拆成多个 chunk 拼接识别。
|
- **单次 utterance ASR 上限 3 分钟**。超出后会拆成多个 chunk 拼接识别。
|
||||||
- **不做端侧 LLM 润色**。本地引擎仅做 ASR;"AI 润色"始终走云端、可配置。v0.2.0 曾尝试引入端侧模型,v0.2.1 回滚以保持零 SPM 依赖。
|
- **不做端侧 LLM 润色**。本地引擎仅做 ASR;"AI 润色"始终走云端、可配置。v0.2.0 曾尝试引入端侧模型,v0.2.1 回滚以保持零 SPM 依赖。
|
||||||
- **URL Scheme `osgkeyboard://`** 任何 App 都可调用。OSGKeyboard 只把它用于"唤醒主 App / 续期 Flow 会话",**不** 传递 API Key 等敏感信息。
|
- **URL Scheme `osgkeyboard://`** 任何 App 都可调用。OSGKeyboard 只把它用于"唤醒主 App / 续期 Flow 会话",**不** 传递 API Key 等敏感信息。
|
||||||
|
|||||||
Executable
+66
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# check_l10n_keys.sh
|
||||||
|
# Compares localization key sets across App / Extension / Shared bundles.
|
||||||
|
# Fails when a key referenced in Shared.strings is missing from any bundle
|
||||||
|
# that should mirror it, or when Shared keys are absent from Shared.strings.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
|
||||||
|
extract_keys() {
|
||||||
|
local file="$1"
|
||||||
|
grep -E '^"[^"]+"' "$file" 2>/dev/null | sed -E 's/^"([^"]+)".*/\1/' | sort -u
|
||||||
|
}
|
||||||
|
|
||||||
|
SHARED_EN="$ROOT/OSGKeyboardShared/en.lproj/Shared.strings"
|
||||||
|
SHARED_ZH="$ROOT/OSGKeyboardShared/zh-Hans.lproj/Shared.strings"
|
||||||
|
APP_EN="$ROOT/OSGKeyboard/en.lproj/Localizable.strings"
|
||||||
|
APP_ZH="$ROOT/OSGKeyboard/zh-Hans.lproj/Localizable.strings"
|
||||||
|
EXT_EN="$ROOT/OSGKeyboardExt/en.lproj/Keyboard.strings"
|
||||||
|
EXT_ZH="$ROOT/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings"
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
|
||||||
|
compare_pair() {
|
||||||
|
local left="$1"
|
||||||
|
local right="$2"
|
||||||
|
local label="$3"
|
||||||
|
local missing
|
||||||
|
missing="$(comm -23 "$left" "$right" || true)"
|
||||||
|
if [[ -n "$missing" ]]; then
|
||||||
|
echo "❌ $label — keys in first file missing from second:"
|
||||||
|
echo "$missing" | sed 's/^/ /'
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
SHARED_EN_KEYS="$(mktemp)"
|
||||||
|
SHARED_ZH_KEYS="$(mktemp)"
|
||||||
|
APP_EN_KEYS="$(mktemp)"
|
||||||
|
APP_ZH_KEYS="$(mktemp)"
|
||||||
|
EXT_EN_KEYS="$(mktemp)"
|
||||||
|
EXT_ZH_KEYS="$(mktemp)"
|
||||||
|
trap 'rm -f "$SHARED_EN_KEYS" "$SHARED_ZH_KEYS" "$APP_EN_KEYS" "$APP_ZH_KEYS" "$EXT_EN_KEYS" "$EXT_ZH_KEYS"' EXIT
|
||||||
|
|
||||||
|
extract_keys "$SHARED_EN" > "$SHARED_EN_KEYS"
|
||||||
|
extract_keys "$SHARED_ZH" > "$SHARED_ZH_KEYS"
|
||||||
|
extract_keys "$APP_EN" > "$APP_EN_KEYS"
|
||||||
|
extract_keys "$APP_ZH" > "$APP_ZH_KEYS"
|
||||||
|
extract_keys "$EXT_EN" > "$EXT_EN_KEYS"
|
||||||
|
extract_keys "$EXT_ZH" > "$EXT_ZH_KEYS"
|
||||||
|
|
||||||
|
compare_pair "$SHARED_EN_KEYS" "$SHARED_ZH_KEYS" "Shared en vs zh-Hans"
|
||||||
|
compare_pair "$SHARED_ZH_KEYS" "$SHARED_EN_KEYS" "Shared zh-Hans vs en"
|
||||||
|
compare_pair "$APP_EN_KEYS" "$APP_ZH_KEYS" "App Localizable en vs zh-Hans"
|
||||||
|
compare_pair "$APP_ZH_KEYS" "$APP_EN_KEYS" "App Localizable zh-Hans vs en"
|
||||||
|
compare_pair "$EXT_EN_KEYS" "$EXT_ZH_KEYS" "Extension Keyboard en vs zh-Hans"
|
||||||
|
compare_pair "$EXT_ZH_KEYS" "$EXT_EN_KEYS" "Extension Keyboard zh-Hans vs en"
|
||||||
|
|
||||||
|
if [[ "$fail" -ne 0 ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "L10n key parity check failed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ L10n key parity check passed (Shared / App / Extension en ↔ zh-Hans)."
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""Build OSGKeyboard custom ASR lexicon v1 from Sogou-derived sources.
|
"""Build OSGKeyboard domain ASR lexicon v1 from the computer-terms scel source.
|
||||||
|
|
||||||
Sources (experimentation only — Sogou data is non-commercial):
|
Sources:
|
||||||
1. ASC8384/SogouPopularDict accumulated pinyin TSV
|
Local 计算机词汇大全【官方推荐】.scel (IT / computer vocabulary only)
|
||||||
2. Local 计算机词汇大全【官方推荐】.scel
|
|
||||||
3. Local 网络流行新词.scel
|
Casual network slang and Sogou popular-word dumps are intentionally excluded —
|
||||||
|
they dilute custom LM phrase biasing without improving domain ASR accuracy.
|
||||||
|
|
||||||
Output:
|
Output:
|
||||||
OSGKeyboard/Resources/CustomLanguageModel/v1/phrases.tsv
|
OSGKeyboard/Resources/CustomLanguageModel/v1/phrases.tsv
|
||||||
OSGKeyboard/Resources/CustomLanguageModel/v1/manifest.json
|
OSGKeyboard/Resources/CustomLanguageModel/v1/manifest.json
|
||||||
|
|
||||||
|
The compiled .bin asset is exported separately to
|
||||||
|
OSGKeyboardShared/Resources/CustomLanguageModel/v1/ via export_clm.swift.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -17,43 +21,19 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
import urllib.request
|
|
||||||
from collections import Counter
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from scel_parser import get_scel_info, load_pinyin_tsv, parse_scel_file
|
from scel_parser import get_scel_info, parse_scel_file
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
DEFAULT_OUTPUT_DIR = REPO_ROOT / "OSGKeyboard/Resources/CustomLanguageModel/v1"
|
DEFAULT_OUTPUT_DIR = REPO_ROOT / "OSGKeyboard/Resources/CustomLanguageModel/v1"
|
||||||
SOGOU_ACCUMULATED_URL = (
|
|
||||||
"https://raw.githubusercontent.com/ASC8384/SogouPopularDict/main/"
|
|
||||||
"data/sogou_network_words_accumulated_pinyin.tsv"
|
|
||||||
)
|
|
||||||
|
|
||||||
DEFAULT_COMPUTER_SCEL = Path("/Users/rocky/Downloads/计算机词汇大全【官方推荐】.scel")
|
DEFAULT_COMPUTER_SCEL = Path("/Users/rocky/Downloads/计算机词汇大全【官方推荐】.scel")
|
||||||
DEFAULT_NETWORK_SCEL = Path("/Users/rocky/Downloads/网络流行新词.scel")
|
|
||||||
|
|
||||||
|
SOURCE_KEY = "computer_terms"
|
||||||
@dataclass(frozen=True)
|
SOURCE_LABEL = "计算机词汇大全【官方推荐】"
|
||||||
class SourceSpec:
|
SOURCE_WEIGHT = 5
|
||||||
key: str
|
|
||||||
label: str
|
|
||||||
weight: int
|
|
||||||
|
|
||||||
|
|
||||||
SOURCES = [
|
|
||||||
SourceSpec("computer_terms", "计算机词汇大全【官方推荐】", weight=5),
|
|
||||||
SourceSpec("network_slang_local", "网络流行新词.scel", weight=3),
|
|
||||||
SourceSpec("sogou_popular_accumulated", "SogouPopularDict accumulated", weight=1),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def download_accumulated_tsv(destination: Path) -> None:
|
|
||||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with urllib.request.urlopen(SOGOU_ACCUMULATED_URL, timeout=120) as response:
|
|
||||||
destination.write_bytes(response.read())
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -64,24 +44,26 @@ class LexiconEntry:
|
|||||||
weight: int
|
weight: int
|
||||||
|
|
||||||
|
|
||||||
def merge_entries(sources: list[tuple[SourceSpec, list[tuple[str, str]]]]) -> list[LexiconEntry]:
|
def merge_entries(entries: list[tuple[str, str]]) -> list[LexiconEntry]:
|
||||||
merged: dict[str, LexiconEntry] = {}
|
merged: dict[str, LexiconEntry] = {}
|
||||||
source_counts: Counter[str] = Counter()
|
|
||||||
|
|
||||||
for spec, entries in sources:
|
for word, pinyin in entries:
|
||||||
for word, pinyin in entries:
|
current = merged.get(word)
|
||||||
source_counts[spec.key] += 1
|
candidate = LexiconEntry(
|
||||||
current = merged.get(word)
|
word=word,
|
||||||
candidate = LexiconEntry(word=word, pinyin=pinyin, source=spec.key, weight=spec.weight)
|
pinyin=pinyin,
|
||||||
if current is None or candidate.weight > current.weight:
|
source=SOURCE_KEY,
|
||||||
merged[word] = candidate
|
weight=SOURCE_WEIGHT,
|
||||||
elif current.weight == candidate.weight and not current.pinyin and pinyin:
|
)
|
||||||
merged[word] = candidate
|
if current is None:
|
||||||
|
merged[word] = candidate
|
||||||
|
elif not current.pinyin and pinyin:
|
||||||
|
merged[word] = candidate
|
||||||
|
|
||||||
return sorted(merged.values(), key=lambda item: (item.weight * -1, item.word))
|
return sorted(merged.values(), key=lambda item: item.word)
|
||||||
|
|
||||||
|
|
||||||
def write_outputs(entries: list[LexiconEntry], output_dir: Path, source_stats: dict[str, int]) -> None:
|
def write_outputs(entries: list[LexiconEntry], output_dir: Path, raw_count: int) -> None:
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
phrases_path = output_dir / "phrases.tsv"
|
phrases_path = output_dir / "phrases.tsv"
|
||||||
@@ -97,17 +79,16 @@ def write_outputs(entries: list[LexiconEntry], output_dir: Path, source_stats: d
|
|||||||
"entry_count": len(entries),
|
"entry_count": len(entries),
|
||||||
"sources": [
|
"sources": [
|
||||||
{
|
{
|
||||||
"key": spec.key,
|
"key": SOURCE_KEY,
|
||||||
"label": spec.label,
|
"label": SOURCE_LABEL,
|
||||||
"weight": spec.weight,
|
"weight": SOURCE_WEIGHT,
|
||||||
"raw_count": source_stats.get(spec.key, 0),
|
"raw_count": raw_count,
|
||||||
}
|
}
|
||||||
for spec in SOURCES
|
|
||||||
],
|
],
|
||||||
"notes": [
|
"notes": [
|
||||||
"Sogou-derived data is for internal ASR experimentation only.",
|
"Domain-specific computer/IT vocabulary only; casual network slang removed.",
|
||||||
"PhraseCount weights map to SFCustomLanguageModelData relative frequencies.",
|
"PhraseCount weights map to SFCustomLanguageModelData relative frequencies.",
|
||||||
"Higher source weight wins on duplicate words.",
|
"Merged with ai-tech-brands seed at export time for the final .bin asset.",
|
||||||
],
|
],
|
||||||
"files": {
|
"files": {
|
||||||
"phrases": phrases_path.name,
|
"phrases": phrases_path.name,
|
||||||
@@ -118,47 +99,19 @@ def write_outputs(entries: list[LexiconEntry], output_dir: Path, source_stats: d
|
|||||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def build(
|
def build(*, computer_scel: Path, output_dir: Path) -> int:
|
||||||
*,
|
|
||||||
computer_scel: Path,
|
|
||||||
network_scel: Path,
|
|
||||||
output_dir: Path,
|
|
||||||
skip_download: bool,
|
|
||||||
) -> int:
|
|
||||||
cache_dir = REPO_ROOT / ".cache/lexicon"
|
|
||||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
accumulated_tsv = cache_dir / "sogou_network_words_accumulated_pinyin.tsv"
|
|
||||||
|
|
||||||
if not skip_download and not accumulated_tsv.exists():
|
|
||||||
print(f"Downloading {SOGOU_ACCUMULATED_URL} …")
|
|
||||||
download_accumulated_tsv(accumulated_tsv)
|
|
||||||
elif not accumulated_tsv.exists():
|
|
||||||
print(f"Missing accumulated TSV: {accumulated_tsv}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
if not computer_scel.exists():
|
if not computer_scel.exists():
|
||||||
print(f"Missing computer scel: {computer_scel}", file=sys.stderr)
|
print(f"Missing computer scel: {computer_scel}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
if not network_scel.exists():
|
|
||||||
print(f"Missing network scel: {network_scel}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
computer_info = get_scel_info(computer_scel)
|
computer_info = get_scel_info(computer_scel)
|
||||||
network_info = get_scel_info(network_scel)
|
|
||||||
print(f"Computer dict: {computer_info.name} ({computer_info.word_count} header count)")
|
print(f"Computer dict: {computer_info.name} ({computer_info.word_count} header count)")
|
||||||
print(f"Network dict: {network_info.name} ({network_info.word_count} header count)")
|
|
||||||
|
|
||||||
loaded_sources: list[tuple[SourceSpec, list[tuple[str, str]]]] = [
|
raw_entries = parse_scel_file(computer_scel)
|
||||||
(SOURCES[0], parse_scel_file(computer_scel)),
|
merged = merge_entries(raw_entries)
|
||||||
(SOURCES[1], parse_scel_file(network_scel)),
|
write_outputs(merged, output_dir, raw_count=len(raw_entries))
|
||||||
(SOURCES[2], load_pinyin_tsv(accumulated_tsv)),
|
|
||||||
]
|
|
||||||
|
|
||||||
source_stats = {spec.key: len(entries) for spec, entries in loaded_sources}
|
print(f"Raw count: {len(raw_entries)}")
|
||||||
merged = merge_entries(loaded_sources)
|
|
||||||
write_outputs(merged, output_dir, source_stats)
|
|
||||||
|
|
||||||
print(f"Raw counts: {source_stats}")
|
|
||||||
print(f"Merged unique entries: {len(merged)}")
|
print(f"Merged unique entries: {len(merged)}")
|
||||||
print(f"Wrote {output_dir / 'phrases.tsv'}")
|
print(f"Wrote {output_dir / 'phrases.tsv'}")
|
||||||
print(f"Wrote {output_dir / 'manifest.json'}")
|
print(f"Wrote {output_dir / 'manifest.json'}")
|
||||||
@@ -166,18 +119,11 @@ def build(
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description="Build OSGKeyboard custom ASR lexicon v1")
|
parser = argparse.ArgumentParser(description="Build OSGKeyboard domain ASR lexicon v1")
|
||||||
parser.add_argument("--computer-scel", type=Path, default=DEFAULT_COMPUTER_SCEL)
|
parser.add_argument("--computer-scel", type=Path, default=DEFAULT_COMPUTER_SCEL)
|
||||||
parser.add_argument("--network-scel", type=Path, default=DEFAULT_NETWORK_SCEL)
|
|
||||||
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR)
|
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR)
|
||||||
parser.add_argument("--skip-download", action="store_true")
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
return build(
|
return build(computer_scel=args.computer_scel, output_dir=args.output_dir)
|
||||||
computer_scel=args.computer_scel,
|
|
||||||
network_scel=args.network_scel,
|
|
||||||
output_dir=args.output_dir,
|
|
||||||
skip_download=args.skip_download,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import Speech
|
|||||||
// MARK: - CLI
|
// MARK: - CLI
|
||||||
|
|
||||||
struct CLIOptions {
|
struct CLIOptions {
|
||||||
var sogouTSV: URL
|
var domainTSV: URL
|
||||||
var aiTechTSV: URL
|
var aiTechTSV: URL
|
||||||
var outputBin: URL
|
var outputBin: URL
|
||||||
var localeID: String
|
var localeID: String
|
||||||
@@ -29,14 +29,14 @@ struct CLIOptions {
|
|||||||
.deletingLastPathComponent()
|
.deletingLastPathComponent()
|
||||||
.deletingLastPathComponent()
|
.deletingLastPathComponent()
|
||||||
|
|
||||||
var sogou = repoRoot.appendingPathComponent(
|
var domain = repoRoot.appendingPathComponent(
|
||||||
"OSGKeyboard/Resources/CustomLanguageModel/v1/phrases.tsv"
|
"OSGKeyboard/Resources/CustomLanguageModel/v1/phrases.tsv"
|
||||||
)
|
)
|
||||||
var aiTech = repoRoot.appendingPathComponent(
|
var aiTech = repoRoot.appendingPathComponent(
|
||||||
"OSGKeyboard/Resources/CustomLanguageModel/ai-tech-brands/v1/phrases.tsv"
|
"OSGKeyboard/Resources/CustomLanguageModel/ai-tech-brands/v1/phrases.tsv"
|
||||||
)
|
)
|
||||||
var output = repoRoot.appendingPathComponent(
|
var output = repoRoot.appendingPathComponent(
|
||||||
"OSGKeyboard/Resources/CustomLanguageModel/v1/OSGKeyboardCLM.bin"
|
"OSGKeyboardShared/Resources/CustomLanguageModel/v1/OSGKeyboardCLM.bin"
|
||||||
)
|
)
|
||||||
var localeID = "zh_CN"
|
var localeID = "zh_CN"
|
||||||
var modelID = "com.osgkeyboard.custom-lm.v1"
|
var modelID = "com.osgkeyboard.custom-lm.v1"
|
||||||
@@ -46,8 +46,8 @@ struct CLIOptions {
|
|||||||
var iterator = CommandLine.arguments.dropFirst().makeIterator()
|
var iterator = CommandLine.arguments.dropFirst().makeIterator()
|
||||||
while let flag = iterator.next() {
|
while let flag = iterator.next() {
|
||||||
switch flag {
|
switch flag {
|
||||||
case "--sogou-tsv":
|
case "--domain-tsv", "--sogou-tsv":
|
||||||
sogou = URL(fileURLWithPath: iterator.next() ?? "")
|
domain = URL(fileURLWithPath: iterator.next() ?? "")
|
||||||
case "--ai-tech-tsv":
|
case "--ai-tech-tsv":
|
||||||
aiTech = URL(fileURLWithPath: iterator.next() ?? "")
|
aiTech = URL(fileURLWithPath: iterator.next() ?? "")
|
||||||
case "--output":
|
case "--output":
|
||||||
@@ -71,7 +71,7 @@ struct CLIOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return CLIOptions(
|
return CLIOptions(
|
||||||
sogouTSV: sogou,
|
domainTSV: domain,
|
||||||
aiTechTSV: aiTech,
|
aiTechTSV: aiTech,
|
||||||
outputBin: output,
|
outputBin: output,
|
||||||
localeID: localeID,
|
localeID: localeID,
|
||||||
@@ -86,7 +86,7 @@ struct CLIOptions {
|
|||||||
export_clm.swift — build SFCustomLanguageModelData .bin on macOS
|
export_clm.swift — build SFCustomLanguageModelData .bin on macOS
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--sogou-tsv <path> Sogou merged phrases TSV
|
--domain-tsv <path> Domain phrases TSV (computer/IT terms)
|
||||||
--ai-tech-tsv <path> AI/tech seed phrases TSV
|
--ai-tech-tsv <path> AI/tech seed phrases TSV
|
||||||
--output <path> Output .bin path
|
--output <path> Output .bin path
|
||||||
--locale <id> Locale identifier (default: zh_CN)
|
--locale <id> Locale identifier (default: zh_CN)
|
||||||
@@ -126,7 +126,7 @@ enum TSVLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Formats:
|
// Formats:
|
||||||
// sogou: word, pinyin, source, weight
|
// domain: word, pinyin, source, weight
|
||||||
// ai-tech: word, pinyin, source, category, weight, canonical
|
// ai-tech: word, pinyin, source, category, weight, canonical
|
||||||
let weight: Int
|
let weight: Int
|
||||||
if parts.count >= 6, let parsed = Int(parts[4]) {
|
if parts.count >= 6, let parsed = Int(parts[4]) {
|
||||||
@@ -171,17 +171,17 @@ enum ExportCLM {
|
|||||||
let options = CLIOptions.parse()
|
let options = CLIOptions.parse()
|
||||||
let fm = FileManager.default
|
let fm = FileManager.default
|
||||||
|
|
||||||
guard fm.fileExists(atPath: options.sogouTSV.path) else {
|
guard fm.fileExists(atPath: options.domainTSV.path) else {
|
||||||
throw ExportError.missingInput(options.sogouTSV.path)
|
throw ExportError.missingInput(options.domainTSV.path)
|
||||||
}
|
}
|
||||||
guard fm.fileExists(atPath: options.aiTechTSV.path) else {
|
guard fm.fileExists(atPath: options.aiTechTSV.path) else {
|
||||||
throw ExportError.missingInput(options.aiTechTSV.path)
|
throw ExportError.missingInput(options.aiTechTSV.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
fputs("Loading phrases…\n", stderr)
|
fputs("Loading phrases…\n", stderr)
|
||||||
let sogou = try TSVLoader.load(from: options.sogouTSV, sourceLabel: "sogou_v1")
|
let domain = try TSVLoader.load(from: options.domainTSV, sourceLabel: "computer_terms")
|
||||||
let aiTech = try TSVLoader.load(from: options.aiTechTSV, sourceLabel: "ai_tech_seed")
|
let aiTech = try TSVLoader.load(from: options.aiTechTSV, sourceLabel: "ai_tech_seed")
|
||||||
var merged = TSVLoader.merge([sogou, aiTech])
|
var merged = TSVLoader.merge([domain, aiTech])
|
||||||
|
|
||||||
if let cap = options.maxEntries, merged.count > cap {
|
if let cap = options.maxEntries, merged.count > cap {
|
||||||
merged = Array(merged.prefix(cap))
|
merged = Array(merged.prefix(cap))
|
||||||
@@ -189,7 +189,7 @@ enum ExportCLM {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fputs(
|
fputs(
|
||||||
"Merged \(merged.count) unique phrases (sogou=\(sogou.count), ai-tech=\(aiTech.count))\n",
|
"Merged \(merged.count) unique phrases (domain=\(domain.count), ai-tech=\(aiTech.count))\n",
|
||||||
stderr
|
stderr
|
||||||
)
|
)
|
||||||
fputs("Locale=\(options.localeID) identifier=\(options.modelID) version=\(options.modelVersion)\n", stderr)
|
fputs("Locale=\(options.localeID) identifier=\(options.modelID) version=\(options.modelVersion)\n", stderr)
|
||||||
@@ -236,7 +236,7 @@ enum ExportCLM {
|
|||||||
"version": options.modelVersion,
|
"version": options.modelVersion,
|
||||||
"phrase_count": merged.count,
|
"phrase_count": merged.count,
|
||||||
"sources": [
|
"sources": [
|
||||||
"sogou_v1": sogou.count,
|
"computer_terms": domain.count,
|
||||||
"ai_tech_seed": aiTech.count,
|
"ai_tech_seed": aiTech.count,
|
||||||
],
|
],
|
||||||
"bin_file": outputURL.lastPathComponent,
|
"bin_file": outputURL.lastPathComponent,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ struct PrepareOptions {
|
|||||||
.deletingLastPathComponent()
|
.deletingLastPathComponent()
|
||||||
|
|
||||||
var input = repoRoot.appendingPathComponent(
|
var input = repoRoot.appendingPathComponent(
|
||||||
"OSGKeyboard/Resources/CustomLanguageModel/v1/OSGKeyboardCLM.bin"
|
"OSGKeyboardShared/Resources/CustomLanguageModel/v1/OSGKeyboardCLM.bin"
|
||||||
)
|
)
|
||||||
var output = repoRoot.appendingPathComponent(
|
var output = repoRoot.appendingPathComponent(
|
||||||
"OSGKeyboard/Resources/CustomLanguageModel/v1/compiled"
|
"OSGKeyboard/Resources/CustomLanguageModel/v1/compiled"
|
||||||
|
|||||||
@@ -379,3 +379,242 @@ embodied AI 具身智能 tech_term 80
|
|||||||
数字游民 shu zi you min digital nomad tech_term 75
|
数字游民 shu zi you min digital nomad tech_term 75
|
||||||
远程办公 yuan cheng ban gong remote work tech_term 78
|
远程办公 yuan cheng ban gong remote work tech_term 78
|
||||||
副业 fu ye side hustle tech_term 72
|
副业 fu ye side hustle tech_term 72
|
||||||
|
|
||||||
|
# --- 2026 AI models, agents & coding tools ---
|
||||||
|
GPT-5 gpt five|GPT five|ChatGPT 5 ai_model 98
|
||||||
|
GPT-5.5 gpt five five|GPT five point five|gpt-5.5 ai_model 98
|
||||||
|
GPT-4o gpt four o|GPT four oh|gpt4o ai_model 92
|
||||||
|
Claude Code claude code|ClaudeCode|克劳德代码 dev_tool 96
|
||||||
|
Claude Sonnet claude sonnet|Sonnet|Sonnet 4|Sonnet 4.6 ai_model 94
|
||||||
|
Claude Opus claude opus|Opus|Opus 4|Opus 4.7 ai_model 94
|
||||||
|
Composer cursor composer|Composer 2|Composer 2.5 dev_tool 92
|
||||||
|
Cursor Composer cursor composer|Composer dev_tool 92
|
||||||
|
OpenAI Codex openai codex|Codex|codex cli dev_tool 92
|
||||||
|
Codex CLI codex cli|OpenAI Codex dev_tool 90
|
||||||
|
Gemini CLI gemini cli dev_tool 88
|
||||||
|
Gemini 2.5 Pro gemini two five pro|Gemini Pro ai_model 90
|
||||||
|
Gemini 3 Pro gemini three pro|Gemini Pro ai_model 92
|
||||||
|
Gemini Flash gemini flash|Gemini 3 Flash|Gemini 2.5 Flash ai_model 88
|
||||||
|
DeepSeek V3 deepseek v three|DeepSeek-V3 ai_model 92
|
||||||
|
DeepSeek R1 deepseek r one|DeepSeek-R1 ai_model 95
|
||||||
|
DeepSeek V4 deepseek v four|DeepSeek-V4|DeepSeek V4 Pro ai_model 92
|
||||||
|
Qwen Coder qwen coder|千问 coder|通义千问 coder ai_model 90
|
||||||
|
Qwen Max qwen max|Qwen3 Max|通义千问 Max ai_model 88
|
||||||
|
Qwen Thinking qwen thinking|通义千问 thinking ai_model 86
|
||||||
|
Kimi K2 kimi k two|Kimi K2.6|Kimi K2.5 ai_model 90
|
||||||
|
Kimi Code kimi code|Moonshot Kimi Code dev_tool 88
|
||||||
|
GLM Coding Plan glm coding plan|智谱 coding plan dev_tool 84
|
||||||
|
GLM-4.6 glm four six|GLM 4.6 ai_model 84
|
||||||
|
GLM-5 glm five|GLM 5 ai_model 86
|
||||||
|
Grok 4 grok four|Grok 4.3|Grok Build ai_model 86
|
||||||
|
Grok Build grok build|Grok Build CLI dev_tool 84
|
||||||
|
Llama 4 llama four|Llama Maverick|Llama Scout ai_model 86
|
||||||
|
Llama Maverick llama maverick|Llama 4 Maverick ai_model 84
|
||||||
|
Mistral Large mistral large|Mistral Large 3 ai_model 84
|
||||||
|
Devin devin|Cognition Devin dev_tool 88
|
||||||
|
Cognition cognition ai|Cognition AI ai_brand 82
|
||||||
|
Kiro kiro|Amazon Kiro dev_tool 84
|
||||||
|
Amazon Q amazon q|AWS Q|Q Developer dev_tool 84
|
||||||
|
Q Developer q developer|Amazon Q Developer dev_tool 82
|
||||||
|
OpenCode open code|opencode|OpenCode AI dev_tool 88
|
||||||
|
OpenHands open hands|OpenHands AI dev_tool 84
|
||||||
|
Roo Code roo code|RooCode dev_tool 80
|
||||||
|
Continue continue dev|Continue.dev dev_tool 78
|
||||||
|
Zed zed editor|Zed AI dev_tool 80
|
||||||
|
Trae trae|Trae AI|Trae CN dev_tool 84
|
||||||
|
TRAE trae|字节 Trae dev_tool 82
|
||||||
|
Lovable lovable|Lovable AI dev_tool 84
|
||||||
|
Bolt.new bolt new|Bolt AI|StackBlitz Bolt dev_tool 84
|
||||||
|
v0 vercel v0|v zero|V0 dev_tool 86
|
||||||
|
Replit Agent replit agent|Replit AI dev_tool 84
|
||||||
|
Manus manus|Manus AI ai_brand 86
|
||||||
|
AutoGen autogen|Microsoft AutoGen ai_platform 82
|
||||||
|
CrewAI crew ai|Crew AI ai_platform 82
|
||||||
|
LangGraph lang graph|LangGraph ai_platform 84
|
||||||
|
DSPy dspy|DSPy AI ai_platform 78
|
||||||
|
LlamaIndex llama index|LlamaIndex ai_platform 82
|
||||||
|
Haystack haystack ai|Deepset Haystack ai_platform 76
|
||||||
|
ComfyUI comfy ui|ComfyUI ai_platform 78
|
||||||
|
Fooocus fooocus|Focus AI ai_platform 72
|
||||||
|
LM Studio lm studio|LMStudio ai_platform 82
|
||||||
|
Open WebUI open web ui|OpenWebUI ai_platform 82
|
||||||
|
AnythingLLM anything llm|Anything LLM ai_platform 78
|
||||||
|
|
||||||
|
# --- Agent, MCP & AI engineering terms ---
|
||||||
|
Agent Mode agent mode|代理模式|智能体模式 ai_term 92
|
||||||
|
智能体模式 zhi neng ti mo shi Agent Mode ai_term 90
|
||||||
|
multi-agent multi agent|多智能体 ai_term 90
|
||||||
|
多智能体 duo zhi neng ti multi-agent ai_term 88
|
||||||
|
subagent sub agent|子智能体 ai_term 86
|
||||||
|
子智能体 zi zhi neng ti subagent ai_term 84
|
||||||
|
tool use tool-use|工具使用 ai_term 88
|
||||||
|
tool calling tool-calling|工具调用 ai_term 90
|
||||||
|
structured output structured outputs|结构化输出 ai_term 86
|
||||||
|
结构化输出 jie gou hua shu chu structured output ai_term 84
|
||||||
|
context engineering context engineering|上下文工程 ai_term 92
|
||||||
|
上下文工程 shang xia wen gong cheng context engineering ai_term 90
|
||||||
|
prompt caching prompt cache|提示词缓存 ai_term 84
|
||||||
|
提示词缓存 ti shi ci huan cun prompt caching ai_term 82
|
||||||
|
semantic search 语义搜索|semantic retrieval ai_term 84
|
||||||
|
语义搜索 yu yi sou suo semantic search ai_term 82
|
||||||
|
vector search 向量搜索|vector retrieval ai_term 84
|
||||||
|
向量搜索 xiang liang sou suo vector search ai_term 82
|
||||||
|
hybrid search 混合搜索|hybrid retrieval ai_term 82
|
||||||
|
reranker rerank|重排序模型 ai_term 82
|
||||||
|
重排序模型 chong pai xu mo xing reranker ai_term 80
|
||||||
|
evals evaluation|模型评测|评测集 ai_term 86
|
||||||
|
模型评测 mo xing ping ce evals ai_term 84
|
||||||
|
SWE-agent swe agent|SWE Agent ai_term 80
|
||||||
|
WebDev Arena web dev arena|WebDevArena ai_term 76
|
||||||
|
LLM harness llm harness|agent harness ai_term 80
|
||||||
|
agent harness agentic harness|智能体框架 ai_term 80
|
||||||
|
reasoning effort reasoning effort|推理强度 ai_term 78
|
||||||
|
推理强度 tui li qiang du reasoning effort ai_term 76
|
||||||
|
test-time compute test time compute|测试时计算 ai_term 78
|
||||||
|
上下文压缩 shang xia wen ya suo context compression ai_term 82
|
||||||
|
context compression 上下文压缩 ai_term 80
|
||||||
|
memory bank memory bank|记忆库 ai_term 78
|
||||||
|
AI workflow ai workflow|AI 工作流 ai_term 82
|
||||||
|
工作流编排 gong zuo liu bian pai workflow orchestration ai_term 82
|
||||||
|
workflow orchestration 工作流编排 ai_term 80
|
||||||
|
MCP server mcp server|模型上下文协议服务器 ai_term 90
|
||||||
|
MCP client mcp client|模型上下文协议客户端 ai_term 86
|
||||||
|
MCP Inspector mcp inspector|MCP 调试器 dev_tool 82
|
||||||
|
Streamable HTTP streamable http|MCP HTTP ai_term 78
|
||||||
|
stdio transport stdio transport|标准输入输出传输 ai_term 76
|
||||||
|
|
||||||
|
# --- Developer platforms, databases & infra ---
|
||||||
|
Neon neon database|Neon Postgres tech_company 84
|
||||||
|
PlanetScale planet scale|PlanetScale MySQL tech_company 82
|
||||||
|
Turso turso|libSQL tech_company 80
|
||||||
|
libSQL lib sql|Turso tech_term 78
|
||||||
|
Convex convex dev|Convex database tech_company 80
|
||||||
|
Clerk clerk auth|Clerk dev_tool 80
|
||||||
|
Auth0 auth zero|Auth Zero dev_tool 78
|
||||||
|
WorkOS work os|Work OS dev_tool 78
|
||||||
|
Clerk Auth clerk auth|Clerk dev_tool 78
|
||||||
|
Drizzle drizzle orm|Drizzle ORM dev_tool 82
|
||||||
|
Prisma prisma orm|Prisma ORM dev_tool 84
|
||||||
|
Drizzle ORM drizzle orm|Drizzle dev_tool 82
|
||||||
|
Prisma ORM prisma orm|Prisma dev_tool 82
|
||||||
|
Postgres postgres|PostgreSQL|postgre sql tech_term 88
|
||||||
|
PostgreSQL postgresql|Postgres|postgre sql tech_term 88
|
||||||
|
SQLite sqlite|SQLite tech_term 84
|
||||||
|
DuckDB duck db|Duck DB tech_term 80
|
||||||
|
ClickHouse click house|ClickHouse tech_term 80
|
||||||
|
Kafka kafka|Apache Kafka tech_term 82
|
||||||
|
Redpanda red panda|Redpanda tech_company 76
|
||||||
|
OpenTelemetry open telemetry|OTel tech_term 84
|
||||||
|
OTel otel|OpenTelemetry tech_term 82
|
||||||
|
Grafana grafana dev_tool 78
|
||||||
|
Prometheus prometheus dev_tool 78
|
||||||
|
Sentry sentry dev_tool 82
|
||||||
|
SST sst|Serverless Stack dev_tool 76
|
||||||
|
Tailscale tailscale tech_company 78
|
||||||
|
Fly.io fly io|Fly tech_company 78
|
||||||
|
Render render.com|Render tech_company 76
|
||||||
|
Railway railway app|Railway tech_company 78
|
||||||
|
Modal modal labs|Modal tech_company 78
|
||||||
|
Modal Labs modal labs|Modal tech_company 76
|
||||||
|
Hugging Face Spaces hugging face spaces|Spaces ai_platform 78
|
||||||
|
Nix nix|NixOS tech_term 78
|
||||||
|
NixOS nixos|Nix OS tech_term 76
|
||||||
|
Bun bun js|Bun runtime dev_tool 82
|
||||||
|
Deno deno|Deno Deploy dev_tool 80
|
||||||
|
Biome biome js|Biome dev_tool 78
|
||||||
|
Turborepo turbo repo|Turborepo dev_tool 78
|
||||||
|
Nx nx monorepo|Nx dev_tool 76
|
||||||
|
pnpm pnpm dev_tool 76
|
||||||
|
shadcn/ui shadcn ui|shadcn|shad cn dev_tool 84
|
||||||
|
Tailwind CSS tailwind css|Tailwind dev_tool 84
|
||||||
|
React Server Components react server components|RSC dev_tool 82
|
||||||
|
RSC react server components dev_tool 80
|
||||||
|
Server Actions server actions|React Server Actions dev_tool 78
|
||||||
|
Astro astro js|Astro dev_tool 80
|
||||||
|
SvelteKit svelte kit|SvelteKit dev_tool 80
|
||||||
|
Remix remix run|Remix dev_tool 78
|
||||||
|
Nuxt nuxt|Nuxt.js dev_tool 78
|
||||||
|
Hono hono js|Hono dev_tool 78
|
||||||
|
FastAPI fast api|FastAPI dev_tool 82
|
||||||
|
Ktor ktor|Ktor server dev_tool 76
|
||||||
|
|
||||||
|
# --- Apple, Swift & on-device speech stack ---
|
||||||
|
SwiftData swift data|SwiftData dev_tool 88
|
||||||
|
Swift Testing swift testing|Testing framework dev_tool 86
|
||||||
|
App Intents app intents|AppIntents dev_tool 84
|
||||||
|
WidgetKit widget kit|WidgetKit dev_tool 82
|
||||||
|
ActivityKit activity kit|ActivityKit dev_tool 82
|
||||||
|
Live Activities live activities|灵动岛实时活动 dev_tool 82
|
||||||
|
App Group app group|App Groups dev_tool 86
|
||||||
|
Core ML core ml|CoreML dev_tool 88
|
||||||
|
Create ML create ml|CreateML dev_tool 82
|
||||||
|
SpeechAnalyzer speech analyzer|Speech Analyzer dev_tool 92
|
||||||
|
DictationTranscriber dictation transcriber|Dictation Transcriber dev_tool 92
|
||||||
|
SpeechTranscriber speech transcriber|Speech Transcriber dev_tool 86
|
||||||
|
SFCustomLanguageModelData custom language model data|SF Custom Language Model Data dev_tool 90
|
||||||
|
SFSpeechLanguageModel speech language model|SF Speech Language Model dev_tool 88
|
||||||
|
AssetInventory asset inventory|Speech AssetInventory dev_tool 84
|
||||||
|
AVAudioEngine av audio engine|AVAudioEngine dev_tool 84
|
||||||
|
AVAudioSession av audio session|AVAudioSession dev_tool 84
|
||||||
|
TestFlight test flight|TestFlight dev_tool 84
|
||||||
|
App Store Connect app store connect|AppStoreConnect dev_tool 84
|
||||||
|
XcodeGen xcode gen|XcodeGen dev_tool 82
|
||||||
|
Tuist tuist dev_tool 78
|
||||||
|
Swift Package Manager swift package manager|SPM dev_tool 82
|
||||||
|
SPM swift package manager dev_tool 80
|
||||||
|
SF Symbols sf symbols|SFSymbols dev_tool 82
|
||||||
|
visionOS vision os|Vision Pro dev_tool 82
|
||||||
|
RealityKit reality kit|RealityKit dev_tool 80
|
||||||
|
Metal metal|Metal Performance Shaders dev_tool 80
|
||||||
|
|
||||||
|
# --- China AI, creator tools & current tech buzzwords ---
|
||||||
|
可灵 ke ling Kling|Kling AI ai_brand 86
|
||||||
|
Kling kling ai|可灵 ai_brand 86
|
||||||
|
海螺AI hai luo AI Hailuo|MiniMax Video ai_brand 84
|
||||||
|
Hailuo hailuo ai|海螺AI ai_brand 82
|
||||||
|
即梦 ji meng Jimeng|即梦AI ai_brand 84
|
||||||
|
Jimeng jimeng ai|即梦 ai_brand 82
|
||||||
|
剪映 jian ying CapCut tech_company 84
|
||||||
|
CapCut cap cut|剪映 tech_company 84
|
||||||
|
腾讯元宝 teng xun yuan bao Yuanbao ai_brand 84
|
||||||
|
元宝 yuan bao 腾讯元宝|Yuanbao ai_brand 82
|
||||||
|
纳米AI na mi AI Nami AI ai_brand 78
|
||||||
|
秘塔AI mi ta AI Metaso ai_brand 80
|
||||||
|
Metaso metaso|秘塔AI ai_brand 78
|
||||||
|
夸克AI kua ke AI Quark AI ai_brand 80
|
||||||
|
Quark quark ai|夸克 ai_brand 78
|
||||||
|
天工 tian gong tiangong|昆仑万维天工 ai_brand 78
|
||||||
|
商量 shang liang SenseChat ai_brand 76
|
||||||
|
杭州六小龙 hang zhou liu xiao long Hangzhou Six Little Dragons tech_term 84
|
||||||
|
AI治理 AI zhi li AI governance tech_term 84
|
||||||
|
AI governance AI治理 tech_term 82
|
||||||
|
世界模型 shi jie mo xing world model ai_term 84
|
||||||
|
world model 世界模型 ai_term 82
|
||||||
|
端到端 duan dao duan end-to-end tech_term 82
|
||||||
|
end-to-end 端到端 tech_term 80
|
||||||
|
端侧模型 duan ce mo xing on-device model ai_term 84
|
||||||
|
on-device model 端侧模型 ai_term 82
|
||||||
|
本地模型 ben di mo xing local model ai_term 84
|
||||||
|
local model 本地模型 ai_term 82
|
||||||
|
具身智能体 ju shen zhi neng ti embodied agent ai_term 82
|
||||||
|
embodied agent 具身智能体 ai_term 80
|
||||||
|
人形机器人 ren xing ji qi ren humanoid robot|humanoid tech_term 86
|
||||||
|
humanoid robot 人形机器人 tech_term 84
|
||||||
|
新质生产力 xin zhi sheng chan li new quality productive forces tech_term 82
|
||||||
|
数字分身 shu zi fen shen digital avatar tech_term 78
|
||||||
|
digital avatar 数字分身 tech_term 76
|
||||||
|
AI视频 AI shi pin AI video tech_term 82
|
||||||
|
AI video AI视频 tech_term 80
|
||||||
|
文生图 wen sheng tu text-to-image tech_term 82
|
||||||
|
图生视频 tu sheng shi pin image-to-video tech_term 82
|
||||||
|
文生视频 wen sheng shi pin text-to-video tech_term 82
|
||||||
|
小红书 xiao hong shu Xiaohongshu|RedNote tech_company 86
|
||||||
|
Xiaohongshu xiao hong shu|小红书|RedNote tech_company 84
|
||||||
|
RedNote red note|Xiaohongshu|小红书 tech_company 82
|
||||||
|
活人感 huo ren gan authentically human|real person vibe tech_term 80
|
||||||
|
情绪价值 qing xu jia zhi emotional value tech_term 80
|
||||||
|
赛博对账 sai bo dui zhang cyber reconciliation tech_term 78
|
||||||
|
赛博 sai bo cyber tech_term 76
|
||||||
|
村咖 cun ka village coffee tech_term 72
|
||||||
|
拉布布 la bu bu Labubu tech_term 72
|
||||||
|
Labubu labubu|拉布布 tech_term 72
|
||||||
|
|||||||
|
@@ -161,8 +161,8 @@
|
|||||||
|
|
||||||
### Phase 4 新增
|
### Phase 4 新增
|
||||||
- [x] 打开 App 后 **自动** 语音会话(权限齐全时)
|
- [x] 打开 App 后 **自动** 语音会话(权限齐全时)
|
||||||
- [x] 杀 App 再开 → **冷启动恢复**(未过期)
|
- [x] 杀 App 再开 → 灵动岛立即清理,**回到 App 时自动开新会话**(不复活旧会话)
|
||||||
- [x] 键盘 **点按** 开始/结束;60s 倒计时 + 最后 10s 变红
|
- [x] 键盘 **点按** 开始/结束;**3.5 分钟(210s)** 倒计时 + 最后 10s 变红
|
||||||
- [x] Onboarding **分步权限** 完整可走通
|
- [x] Onboarding **分步权限** 完整可走通
|
||||||
- [x] 隐私政策 URL 可访问;App 内可打开
|
- [x] 隐私政策 URL 可访问;App 内可打开
|
||||||
- [ ] App Store 隐私标签与政策一致(A3 待人工)
|
- [ ] App Store 隐私标签与政策一致(A3 待人工)
|
||||||
@@ -176,8 +176,8 @@
|
|||||||
2. 切备忘录 → **点按**键盘麦开始 → 再点结束 → **文字出现**
|
2. 切备忘录 → **点按**键盘麦开始 → 再点结束 → **文字出现**
|
||||||
3. **不跳主 App**,重复 5 次
|
3. **不跳主 App**,重复 5 次
|
||||||
4. 本地模式 + 云端模式各测 1 次
|
4. 本地模式 + 云端模式各测 1 次
|
||||||
5. 60s 倒计时 + 最后 10 秒变红;到点自动识别
|
5. 3.5 分钟(210s)倒计时 + 最后 10 秒变红;到点自动识别
|
||||||
6. 杀 App 再开 → 会话恢复(未过期时)
|
6. 杀 App 再开 → 灵动岛立即清理;回到 App 时自动开新会话(不复活旧会话)
|
||||||
7. 预览 sheet「开始/停止录音」仍可用(Flow 未启动时)
|
7. 预览 sheet「开始/停止录音」仍可用(Flow 未启动时)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -194,9 +194,9 @@
|
|||||||
| 会话未启动 | 保持现有逻辑(拉 App / 提示去主 App) |
|
| 会话未启动 | 保持现有逻辑(拉 App / 提示去主 App) |
|
||||||
| 权限引导 | 欢迎 → 麦克风 → 语音识别 → 键盘+完全访问 → 引擎/API;**仅首次或权限未定时** |
|
| 权限引导 | 欢迎 → 麦克风 → 语音识别 → 键盘+完全访问 → 引擎/API;**仅首次或权限未定时** |
|
||||||
| 自动开语音会话 | 进 App 且权限齐全 → **自动开**;有效会话 → **续期**;**不设关闭开关** |
|
| 自动开语音会话 | 进 App 且权限齐全 → **自动开**;有效会话 → **续期**;**不设关闭开关** |
|
||||||
| 冷启动恢复 | 参考 TypeWhisper `checkExistingSession` |
|
| 冷启动恢复 | **杀 App 不复活旧会话**;回到前台由 `activateOnForeground()` 清理孤儿灵动岛并自动开新会话(权限齐全时) |
|
||||||
| Home 按钮 | 自动开后 **隐藏「启动」**;**保留「结束」**;失败显示原因 + 去设置 |
|
| Home 按钮 | 自动开后 **隐藏「启动」**;**保留「结束」**;失败显示原因 + 去设置 |
|
||||||
| 单次录音上限 | **60s**;键盘到点 auto `stopped`;倒计时 **A(剩余)+ C(最后 10s 变红)**,显示在按钮内 |
|
| 单次录音上限 | **210s(3.5 分钟)**;键盘到点 auto `stopped`;倒计时 **A(剩余)+ C(最后 10s 变红)**,显示在按钮内 |
|
||||||
| 隐私政策 URL | **GitHub Pages**(仓库站点,如 `…/privacy`) |
|
| 隐私政策 URL | **GitHub Pages**(仓库站点,如 `…/privacy`) |
|
||||||
| 自动开会话开关 | **先不加** |
|
| 自动开会话开关 | **先不加** |
|
||||||
|
|
||||||
@@ -226,13 +226,13 @@
|
|||||||
- [x] **C3** Home 卡片 UX:进行中 + 结束;失败原因 + 去设置;隐藏手动「启动」
|
- [x] **C3** Home 卡片 UX:进行中 + 结束;失败原因 + 去设置;隐藏手动「启动」
|
||||||
- [x] **C4** 与 `scenePhase.active` 续期对齐
|
- [x] **C4** 与 `scenePhase.active` 续期对齐
|
||||||
|
|
||||||
### 7.5 批次 D · 键盘点按录音 + 60s 倒计时
|
### 7.5 批次 D · 键盘点按录音 + 3.5 分钟(210s)倒计时
|
||||||
|
|
||||||
- [x] **D1** `RecordButton` 改为 toggle(替换长按手势)
|
- [x] **D1** `RecordButton` 改为 toggle(替换长按手势)
|
||||||
- [x] **D2** 识别中禁用按钮
|
- [x] **D2** 识别中禁用按钮
|
||||||
- [x] **D3** 按钮内剩余时间倒计时(`M:SS`)
|
- [x] **D3** 按钮内剩余时间倒计时(`M:SS`)
|
||||||
- [x] **D4** 最后 10 秒变红/橙(A+C)
|
- [x] **D4** 最后 10 秒变红/橙(A+C)
|
||||||
- [x] **D5** 60s 到点自动 `stopped` → 「识别中…」
|
- [x] **D5** 210s(3.5 分钟)到点自动 `stopped` → 「识别中…」
|
||||||
- [x] **D6** 无障碍 / 占位文案改为「点按说话」类
|
- [x] **D6** 无障碍 / 占位文案改为「点按说话」类
|
||||||
|
|
||||||
### 7.6 批次 E · 多语言完善
|
### 7.6 批次 E · 多语言完善
|
||||||
@@ -244,12 +244,12 @@
|
|||||||
### 7.7 批次 F · Phase 3 收尾
|
### 7.7 批次 F · Phase 3 收尾
|
||||||
|
|
||||||
- [x] **F1** B3:会话过期键盘提示、Darwin(可选)、路由(可选)
|
- [x] **F1** B3:会话过期键盘提示、Darwin(可选)、路由(可选)
|
||||||
- [ ] **F2** B4:全场景回归(含自动开、60s、杀 App 恢复)— 待真机
|
- [ ] **F2** B4:全场景回归(含自动开、210s、杀 App 不复活/回前台自动开)— 待真机
|
||||||
- [x] **F3** 更新 §5 验收勾选
|
- [x] **F3** 更新 §5 验收勾选
|
||||||
|
|
||||||
### 7.8 任务追踪(Phase 4)
|
### 7.8 任务追踪(Phase 4)
|
||||||
|
|
||||||
- [x] P4-0:产品规格拍板(交互、权限、自动会话、60s、隐私 URL)
|
- [x] P4-0:产品规格拍板(交互、权限、自动会话、210s、隐私 URL)
|
||||||
- [ ] P4-A:上架合规批次(A1/A2/A5 代码侧已完成;A3/A4 待人工)
|
- [ ] P4-A:上架合规批次(A1/A2/A5 代码侧已完成;A3/A4 待人工)
|
||||||
- [x] P4-B:权限引导
|
- [x] P4-B:权限引导
|
||||||
- [x] P4-C:会话自动化
|
- [x] P4-C:会话自动化
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user