perf(asr): speed up local Flow dictation and land CLM/keyboard refactor
Reduce perceived latency from key release to final text: - Adaptive chunking: 2.5s first chunk + 5s follow-ups so short utterances start on-device recognition while still recording. - Session-level ASR warmup and audio-format cache reuse to remove per-utterance cold-start of SpeechAnalyzer. - Mirror live pipelined partials to the keyboard transcript line via a new flow.transcriptionPartial App Group key + Darwin ping. Also commits the accumulated custom language model, Flow session, keyboard extension restructure, and Xiaomi MiMo provider work in progress on this branch.
This commit is contained in:
@@ -58,6 +58,9 @@ PreconfiguredKeys.local.swift
|
||||
# Lexicon build cache (downloaded SogouPopularDict TSV)
|
||||
.cache/
|
||||
|
||||
# Custom LM compile output (Mac-only; device-side prepare uses .bin from bundle)
|
||||
OSGKeyboard/Resources/CustomLanguageModel/v1/compiled/
|
||||
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -8,6 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **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,与其他在线服务相同管线)。
|
||||
|
||||
### Changed
|
||||
- **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`),精确移动光标。
|
||||
- **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`,用于管理自定义词条与别名。
|
||||
|
||||
@@ -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 |
@@ -6,76 +6,24 @@ import OSGKeyboardShared
|
||||
|
||||
@main
|
||||
struct OSGKeyboardApp: App {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
@StateObject private var config = ProviderConfig.shared
|
||||
@StateObject private var dictationCoordinator = DictationSessionCoordinator()
|
||||
@StateObject private var flowManager = FlowSessionManager()
|
||||
|
||||
init() {
|
||||
MaterialIconsFont.registerIfNeeded()
|
||||
// v0.2.0: no backend-specific ASR provider to install here.
|
||||
// The local engine uses iOS 26 `SpeechAnalyzer` +
|
||||
// `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.
|
||||
if AppGroup.isAvailable {
|
||||
CustomLanguageModelManager.shared.prepareInBackgroundIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ThemedRoot {
|
||||
if AppGroup.isAvailable {
|
||||
if config.hasCompletedOnboarding {
|
||||
MainTabView()
|
||||
} else {
|
||||
OnboardingView(config: config)
|
||||
}
|
||||
} else {
|
||||
if AppGroup.isAvailable {
|
||||
ThemedRoot {
|
||||
MainAppRoot()
|
||||
}
|
||||
} else {
|
||||
ThemedRoot {
|
||||
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",
|
||||
"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",
|
||||
"entry_count": 749,
|
||||
"entry_count": 1259,
|
||||
"seed_file": "Scripts/lexicon/seeds/ai_tech_brands_seed.tsv",
|
||||
"license": "MIT (curated seed; OSGKeyboard contributors)",
|
||||
"categories": {
|
||||
"ai_brand": 133,
|
||||
"ai_model": 31,
|
||||
"ai_platform": 20,
|
||||
"ai_term": 116,
|
||||
"dev_tool": 74,
|
||||
"ai_brand": 169,
|
||||
"ai_model": 90,
|
||||
"ai_platform": 53,
|
||||
"ai_term": 197,
|
||||
"dev_tool": 283,
|
||||
"fintech": 7,
|
||||
"tech_company": 280,
|
||||
"tech_company": 314,
|
||||
"tech_leader": 37,
|
||||
"tech_term": 51
|
||||
"tech_term": 109
|
||||
},
|
||||
"notes": [
|
||||
"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
|
||||
OpenAI ai_tech_seed ai_brand 100 OpenAI
|
||||
深度求索 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
|
||||
Nvidia ai_tech_seed tech_company 98 Nvidia
|
||||
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
|
||||
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
|
||||
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
|
||||
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 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 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 智能体
|
||||
Amazon Web Services 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
|
||||
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 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
|
||||
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
|
||||
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 model ai_tech_seed ai_term 92 大模型
|
||||
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 server 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 提示词
|
||||
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
|
||||
TikTok ai_tech_seed tech_company 92 TikTok
|
||||
we chat 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 大模型
|
||||
大语言模型 ai_tech_seed ai_term 92 LLM
|
||||
宁德时代 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
|
||||
提示词 ti shi ci 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_leader 92 Elon Musk
|
||||
agentic ai_tech_seed ai_term 90 agent
|
||||
AGI ai_tech_seed ai_term 90 AGI
|
||||
agi ai_tech_seed ai_term 90 AGI
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -164,6 +218,8 @@ 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 微调
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
乔布斯 qiao bu si ai_tech_seed tech_leader 90 乔布斯
|
||||
低秩适配 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 ai_term 90 multi-agent
|
||||
大疆 ai_tech_seed tech_company 90 DJI
|
||||
小鹏汽车 ai_tech_seed tech_company 90 XPeng
|
||||
嵌入 ai_tech_seed ai_term 90 embedding
|
||||
工具调用 ai_tech_seed ai_term 90 tool calling
|
||||
微调 wei tiao ai_tech_seed ai_term 90 微调
|
||||
抖音 ai_tech_seed tech_company 90 Douyin
|
||||
文心一言 wen xin yi yan ai_tech_seed ai_brand 90 文心一言
|
||||
智谱 ai_tech_seed ai_brand 90 GLM
|
||||
月之暗面 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 NIO
|
||||
豆包 dou bao ai_tech_seed ai_brand 90 豆包
|
||||
通义千问 coder ai_tech_seed ai_model 90 Qwen Coder
|
||||
通用人工智能 ai_tech_seed ai_term 90 AGI
|
||||
雷军 lei jun ai_tech_seed tech_leader 90 雷军
|
||||
黄仁勋 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
|
||||
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 上下文窗口
|
||||
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
|
||||
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
|
||||
dou bao ai_tech_seed ai_brand 88 Doubao
|
||||
ernie ai_tech_seed ai_brand 88 ERNIE
|
||||
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
|
||||
GenAI ai_tech_seed ai_term 88 GenAI
|
||||
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.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
|
||||
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
|
||||
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 多模态
|
||||
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
|
||||
open code ai_tech_seed dev_tool 88 OpenCode
|
||||
openai o1 ai_tech_seed ai_model 88 o1
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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 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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
多模态 duo mo tai ai_tech_seed ai_term 88 多模态
|
||||
山姆奥特曼 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 幻觉
|
||||
思维链 ai_tech_seed ai_term 88 chain of thought
|
||||
拼多多 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 Intel
|
||||
蚂蚁集团 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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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 星火
|
||||
量化 liang hua ai_tech_seed ai_term 85 量化
|
||||
高通 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 零一万物
|
||||
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 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
|
||||
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 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 百川
|
||||
Baichuan ai_tech_seed ai_brand 82 百川
|
||||
baichuan ai_tech_seed ai_brand 82 Baichuan
|
||||
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
|
||||
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
|
||||
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
|
||||
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 具身智能
|
||||
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
|
||||
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
|
||||
hailuo ai ai_tech_seed ai_brand 82 Hailuo
|
||||
horizon 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
|
||||
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
|
||||
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 零一万物
|
||||
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
|
||||
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
|
||||
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
|
||||
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 weights ai_tech_seed ai_term 82 open weight
|
||||
OpenRouter ai_tech_seed ai_platform 82 OpenRouter
|
||||
OpenWebUI ai_tech_seed ai_platform 82 Open WebUI
|
||||
Optimus ai_tech_seed tech_term 82 人形机器人
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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 流式
|
||||
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
|
||||
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
|
||||
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.js 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
|
||||
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
|
||||
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 零一万物
|
||||
人形机器人 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
|
||||
余承东 yu cheng dong ai_tech_seed tech_leader 82 余承东
|
||||
元宝 yuan bao ai_tech_seed ai_brand 82 元宝
|
||||
全自动驾驶 ai_tech_seed tech_term 82 FSD
|
||||
具身智能 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 区块链
|
||||
吉利 ai_tech_seed tech_company 82 Geely
|
||||
向量数据库 ai_tech_seed ai_term 82 vector database
|
||||
商汤 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
|
||||
字节 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 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 流式
|
||||
深度研究 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 tech_leader 82 Sundar Pichai
|
||||
知识蒸馏 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 ai_term 82 distillation
|
||||
贝索斯 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 零一万物
|
||||
预训练 ai_tech_seed ai_term 82 pretraining
|
||||
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
|
||||
authentically human ai_tech_seed tech_term 80 活人感
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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 低空经济
|
||||
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
|
||||
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
|
||||
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 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
|
||||
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-agent ai_tech_seed ai_term 80 SWE-agent
|
||||
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
|
||||
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
|
||||
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
|
||||
低空经济 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
|
||||
情绪价值 qing xu jia zhi ai_tech_seed tech_term 80 情绪价值
|
||||
擎天柱 ai_tech_seed tech_term 80 Optimus
|
||||
旷视 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 mi ta AI ai_tech_seed ai_brand 80 秘塔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
|
||||
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
|
||||
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 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
|
||||
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
|
||||
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 出海
|
||||
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
|
||||
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 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 dev_tool 78 Linear
|
||||
linear app ai_tech_seed tech_company 78 Linear
|
||||
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
|
||||
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 元宇宙
|
||||
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
|
||||
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
|
||||
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 远程办公
|
||||
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 Agent ai_tech_seed dev_tool 78 Replit
|
||||
robin hood ai_tech_seed fintech 78 Robinhood
|
||||
Robin Hood 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
|
||||
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
|
||||
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
|
||||
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
|
||||
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 元宇宙
|
||||
全球化 chu hai ai_tech_seed tech_term 78 出海
|
||||
出海 chu hai ai_tech_seed tech_term 78 出海
|
||||
基准测试 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 远程办公
|
||||
长城汽车 ai_tech_seed tech_company 78 Great Wall
|
||||
面壁智能 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
|
||||
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 数字游民
|
||||
越狱 ai_tech_seed ai_term 75 jailbreak
|
||||
面壁 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 躺平
|
||||
side hustle ai_tech_seed tech_term 72 副业
|
||||
village coffee 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 躺平
|
||||
|
||||
|
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",
|
||||
"generated_at": "2026-07-05T07:47:30.162461+00:00",
|
||||
"generated_at": "2026-07-05T11:27:48.823095+00:00",
|
||||
"locale": "zh-Hans",
|
||||
"entry_count": 128743,
|
||||
"entry_count": 10300,
|
||||
"sources": [
|
||||
{
|
||||
"key": "computer_terms",
|
||||
"label": "计算机词汇大全【官方推荐】",
|
||||
"weight": 5,
|
||||
"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": [
|
||||
"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.",
|
||||
"Higher source weight wins on duplicate words."
|
||||
"Merged with ai-tech-brands seed at export time for the final .bin asset."
|
||||
],
|
||||
"files": {
|
||||
"phrases": "phrases.tsv"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,22 +1,14 @@
|
||||
// FlowDiagnostics.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Structured logging for the Flow dictation pipeline. Visible in Xcode
|
||||
// console (DEBUG) and Console.app via `subsystem: com.osgkeyboard.ios`.
|
||||
// Structured logging for the Flow dictation pipeline. Delegates to
|
||||
// `OSGLog.flow` for Console.app visibility.
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
import OSGKeyboardShared
|
||||
|
||||
enum FlowDiagnostics {
|
||||
private static let logger = Logger(
|
||||
subsystem: "com.osgkeyboard.ios",
|
||||
category: "Flow"
|
||||
)
|
||||
|
||||
static func log(_ message: String) {
|
||||
logger.info("\(message, privacy: .public)")
|
||||
#if DEBUG
|
||||
print("🌊[OSGFlow] \(message)")
|
||||
#endif
|
||||
OSGLog.flow.info("\(message, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
|
||||
private let capture = FlowContinuousCapture()
|
||||
private let store = AppGroupStore()
|
||||
/// Cloud-engine polish; local engine now ALSO runs through the
|
||||
/// polisher when `localModeCloudPolishEnabled` is on — the same
|
||||
/// `PolishingService` short-circuits to raw when the toggle is off.
|
||||
/// Cloud-engine polish; local engine runs through built-in DeepSeek polish.
|
||||
private var polisher: PolishingService {
|
||||
PolishingService()
|
||||
}
|
||||
@@ -33,12 +31,13 @@ final class FlowSessionManager: ObservableObject {
|
||||
/// factory-built service straight back without going through the
|
||||
/// old `OnDeviceModelWarmup` registry.
|
||||
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 {
|
||||
if let sessionASR { return sessionASR }
|
||||
let service = ASRServiceFactory.make(
|
||||
engineMode: store.engineMode,
|
||||
localBackend: store.localASRBackend
|
||||
)
|
||||
let service = ASRServiceFactory.make()
|
||||
sessionASR = service
|
||||
return service
|
||||
}
|
||||
@@ -155,7 +154,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
// 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()
|
||||
bindSessionASRIfNeeded()
|
||||
scheduleASRWarmup()
|
||||
|
||||
debug("Flow session restored (\(Int(remaining))s remaining)")
|
||||
}
|
||||
@@ -192,6 +192,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
endBackgroundKeepAlive()
|
||||
ScreenWakeLock.release()
|
||||
sessionASR = nil
|
||||
sessionASREngineMode = nil
|
||||
sessionASRWarmedLocaleID = nil
|
||||
FlowSessionBridge.markSessionInactive()
|
||||
FlowSessionDarwin.postSessionChanged()
|
||||
isActive = false
|
||||
@@ -219,13 +221,11 @@ final class FlowSessionManager: ObservableObject {
|
||||
func handleScenePhase(_ phase: ScenePhase) {
|
||||
switch phase {
|
||||
case .active:
|
||||
FlowAppLifecycle.shared.setForeground(true)
|
||||
setAppForeground(true)
|
||||
resumeAfterForeground()
|
||||
case .inactive:
|
||||
writeHeartbeatIfActive()
|
||||
case .background:
|
||||
FlowAppLifecycle.shared.setForeground(false)
|
||||
setAppForeground(false)
|
||||
beginBackgroundKeepAlive()
|
||||
@unknown default:
|
||||
@@ -269,7 +269,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
await self?.reactivateCaptureIfNeeded()
|
||||
// v0.2.0: iOS `SpeechAnalyzer` is bundled with the OS; no
|
||||
// on-device weights to reload after a background trip.
|
||||
self?.bindSessionASR()
|
||||
self?.bindSessionASRIfNeeded()
|
||||
self?.scheduleASRWarmup()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,16 +337,39 @@ final class FlowSessionManager: ObservableObject {
|
||||
// v0.2.0: iOS `SpeechAnalyzer` needs no warm-up; just refresh
|
||||
// the cached ASR service in case the user flipped engines
|
||||
// while the session was idle.
|
||||
bindSessionASR()
|
||||
bindSessionASRIfNeeded()
|
||||
scheduleASRWarmup()
|
||||
|
||||
debug("Flow session started (\(Int(duration))s), continuous capture running")
|
||||
}
|
||||
|
||||
private func bindSessionASR() {
|
||||
sessionASR = ASRServiceFactory.make(
|
||||
engineMode: store.engineMode,
|
||||
localBackend: store.localASRBackend
|
||||
)
|
||||
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 {
|
||||
@@ -384,7 +408,10 @@ final class FlowSessionManager: ObservableObject {
|
||||
|
||||
private func beginUtterance() {
|
||||
guard capture.running else {
|
||||
failUtterance(message: AppL10n.string("flow.error.audioUnavailable"))
|
||||
failUtterance(
|
||||
message: AppL10n.string("flow.error.audioUnavailable"),
|
||||
kind: .audioUnavailable
|
||||
)
|
||||
return
|
||||
}
|
||||
guard !isUtteranceProcessing else {
|
||||
@@ -392,8 +419,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
// Honor engine / ASR backend changes without restarting the session.
|
||||
bindSessionASR()
|
||||
// Usually already warm from session start; refresh without blocking the mic gate.
|
||||
scheduleASRWarmup()
|
||||
|
||||
currentPartial = ""
|
||||
lastFinal = ""
|
||||
@@ -411,7 +438,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
isUtteranceRecording = true
|
||||
utteranceRecordingStartedAt = Date()
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -419,6 +446,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
let outcome = await pipeline.transcribe(stream: stream) { partial in
|
||||
Task { @MainActor in
|
||||
manager?.currentPartial = partial
|
||||
FlowSessionBridge.storeTranscriptionPartial(partial)
|
||||
}
|
||||
}
|
||||
// Re-bind `manager` inside the `@MainActor` block so the
|
||||
@@ -439,9 +467,9 @@ final class FlowSessionManager: ObservableObject {
|
||||
case .failure(let message):
|
||||
manager.debug("asr error: \(message)")
|
||||
if manager.isUtteranceRecording {
|
||||
manager.failUtterance(message: message)
|
||||
manager.failUtterance(message: message, kind: .asrFailed)
|
||||
} else if manager.isUtteranceProcessing {
|
||||
manager.finishProcessing(withError: message)
|
||||
manager.finishProcessing(withError: message, kind: .asrFailed)
|
||||
}
|
||||
case .cancelled:
|
||||
break
|
||||
@@ -487,11 +515,15 @@ final class FlowSessionManager: ObservableObject {
|
||||
currentPartial = ""
|
||||
lastFinal = ""
|
||||
chunkWarnings = []
|
||||
FlowSessionBridge.storeTranscriptionPartial("")
|
||||
FlowSessionBridge.setRecordingState(.idle)
|
||||
debug("utterance aborted")
|
||||
}
|
||||
|
||||
private func failUtterance(message: String) {
|
||||
private func failUtterance(
|
||||
message: String,
|
||||
kind: FlowSessionKeys.TranscriptionErrorKind = .asrFailed
|
||||
) {
|
||||
isUtteranceRecording = false
|
||||
isUtteranceProcessing = false
|
||||
utteranceRecordingStartedAt = nil
|
||||
@@ -505,12 +537,16 @@ final class FlowSessionManager: ObservableObject {
|
||||
currentPartial = ""
|
||||
lastFinal = ""
|
||||
chunkWarnings = []
|
||||
FlowSessionBridge.storeTranscriptionError(message)
|
||||
FlowSessionBridge.storeTranscriptionPartial("")
|
||||
FlowSessionBridge.storeTranscriptionError(message, kind: kind)
|
||||
FlowSessionBridge.setRecordingState(.idle)
|
||||
debug("utterance failed: \(message)")
|
||||
}
|
||||
|
||||
private func finishProcessing(withError message: String) {
|
||||
private func finishProcessing(
|
||||
withError message: String,
|
||||
kind: FlowSessionKeys.TranscriptionErrorKind = .asrFailed
|
||||
) {
|
||||
isUtteranceProcessing = false
|
||||
utteranceRecordingStartedAt = nil
|
||||
finalizeTask?.cancel()
|
||||
@@ -519,7 +555,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
currentPartial = ""
|
||||
lastFinal = ""
|
||||
chunkWarnings = []
|
||||
FlowSessionBridge.storeTranscriptionError(message)
|
||||
FlowSessionBridge.storeTranscriptionPartial("")
|
||||
FlowSessionBridge.storeTranscriptionError(message, kind: kind)
|
||||
FlowSessionBridge.setRecordingState(.idle)
|
||||
debug("utterance processing failed: \(message)")
|
||||
}
|
||||
@@ -533,8 +570,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
|
||||
let asrWait = asrWaitTimeout()
|
||||
FlowDiagnostics.log(
|
||||
"finalize start asrWait=\(Int(asrWait))s engine=\(store.engineMode) " +
|
||||
"backend=\(store.localASRBackend.rawValue)"
|
||||
"finalize start asrWait=\(Int(asrWait))s engine=\(store.engineMode)"
|
||||
)
|
||||
|
||||
let asrDeadline = Date().addingTimeInterval(asrWait)
|
||||
@@ -560,10 +596,13 @@ final class FlowSessionManager: ObservableObject {
|
||||
let key = (asrTask?.isCancelled == true)
|
||||
? "flow.error.recognitionInterrupted"
|
||||
: "flow.error.noSpeech"
|
||||
let kind: FlowSessionKeys.TranscriptionErrorKind =
|
||||
(asrTask?.isCancelled == true) ? .recognitionInterrupted : .noSpeech
|
||||
FlowDiagnostics.log("finalize failed: empty transcript after \(String(format: "%.1f", asrElapsed))s")
|
||||
utteranceRecordingStartedAt = nil
|
||||
FlowSessionBridge.storeTranscriptionError(
|
||||
AppL10n.string(key)
|
||||
AppL10n.string(key),
|
||||
kind: kind
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -576,33 +615,12 @@ final class FlowSessionManager: ObservableObject {
|
||||
// from the keyboard extension are visible before polish/translate.
|
||||
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
|
||||
let polishStarted = Date()
|
||||
let polishMode = pipelineStore.polishModeForPipeline
|
||||
FlowDiagnostics.log(
|
||||
"finalize LLM mode=\(Self.polishModeLogLabel(polishMode)) " +
|
||||
"translationTarget=\(pipelineStore.translationTargetLocaleId) " +
|
||||
"cloudPolish=\(pipelineStore.localModeCloudPolishEnabled)"
|
||||
"translationTarget=\(pipelineStore.translationTargetLocaleId)"
|
||||
)
|
||||
do {
|
||||
let polished = try await polisher.polish(
|
||||
@@ -640,6 +658,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
currentPartial = ""
|
||||
lastFinal = ""
|
||||
chunkWarnings = []
|
||||
FlowSessionBridge.storeTranscriptionPartial("")
|
||||
chunkedPipeline = nil
|
||||
debug("utterance finalized length=\(text.count)")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -337,8 +337,7 @@ struct HomeView: View {
|
||||
EngineServiceLabel.summary(
|
||||
engineMode: config.engineMode,
|
||||
providerId: config.providerId,
|
||||
model: config.model,
|
||||
localASRBackend: config.localASRBackend
|
||||
model: config.model
|
||||
)
|
||||
)
|
||||
.font(TypeStyle.caption2)
|
||||
|
||||
@@ -143,8 +143,7 @@ struct KeyboardPreviewSheet: View {
|
||||
EngineServiceLabel.summary(
|
||||
engineMode: config.engineMode,
|
||||
providerId: config.providerId,
|
||||
model: config.model,
|
||||
localASRBackend: config.localASRBackend
|
||||
model: config.model
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// 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)
|
||||
.onAppear {
|
||||
flowManager.setAppForeground(scenePhase == .active)
|
||||
}
|
||||
.onOpenURL { url in
|
||||
guard url.scheme == "osgkeyboard" else { return }
|
||||
switch url.host {
|
||||
case "startflow":
|
||||
flowManager.startSession()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
.onChange(of: config.hasCompletedOnboarding) { _, done in
|
||||
if done {
|
||||
flowManager.autoStartIfNeeded()
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
flowManager.handleScenePhase(phase)
|
||||
guard phase == .active, config.hasCompletedOnboarding else { return }
|
||||
if flowManager.isActive {
|
||||
flowManager.extendSession()
|
||||
} else {
|
||||
flowManager.autoStartIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,9 +107,7 @@ struct OnboardingView: View {
|
||||
private func applyOnboardingDefaultsIfNeeded() {
|
||||
guard !config.hasCompletedOnboarding, config.onboardingPage == 0 else { return }
|
||||
// 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`
|
||||
// is the only local option, so we don't need to mutate
|
||||
// `config.localASRBackend` here.
|
||||
// v0.2.0: iOS SpeechAnalyzer is the only on-device ASR path.
|
||||
if config.apiKey.isEmpty, config.engineMode == "cloud" {
|
||||
config.engineMode = "local"
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ enum ProviderLogo {
|
||||
case "qwen": return "qwen"
|
||||
case "moonshot": return "moonshot"
|
||||
case "zhipu": return "zhipu"
|
||||
case "mimo": return "mimo"
|
||||
case "custom": return "custom"
|
||||
default: return nil
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"provider.qwen" = "Qwen (DashScope)";
|
||||
"provider.zhipu" = "Zhipu GLM";
|
||||
"provider.moonshot" = "Moonshot";
|
||||
"provider.mimo" = "Xiaomi MiMo";
|
||||
"provider.custom" = "Custom";
|
||||
"settings.api.title" = "API";
|
||||
"settings.language.title" = "Language";
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"provider.qwen" = "通义千问";
|
||||
"provider.zhipu" = "智谱 GLM";
|
||||
"provider.moonshot" = "月之暗面";
|
||||
"provider.mimo" = "小米 MiMo";
|
||||
"provider.custom" = "自定义";
|
||||
"settings.api.title" = "接口";
|
||||
"settings.language.title" = "语言";
|
||||
|
||||
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.
|
||||
state.mode = .polish
|
||||
state.engineMode = store.engineMode
|
||||
state.localASRBackend = store.localASRBackend
|
||||
// v0.2.1 follow-up: only the target locale is persisted —
|
||||
// `translationEnabled` is derived from it. Hydrate once at
|
||||
// startup; `refreshRuntimeFlags` keeps the chip in sync while
|
||||
@@ -42,16 +41,10 @@ public struct AppGroupPersistor {
|
||||
state.translationTargetLocaleId = store.translationTargetLocaleId
|
||||
state.handednessPreference = store.handednessPreference
|
||||
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
|
||||
state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled
|
||||
state.micDisabled = store.isCloudAPIKeyMissingForVoiceInput
|
||||
state.micDisabledHint = store.isCloudAPIKeyMissingForVoiceInput
|
||||
? 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
|
||||
// Print a masked view of the live App Group config so we can see
|
||||
@@ -74,7 +67,6 @@ public struct AppGroupPersistor {
|
||||
model = \(store.model)
|
||||
modeId = \(store.modeId)
|
||||
localeId = \(store.localeId)
|
||||
localASRBackend = \(store.localASRBackend.rawValue)
|
||||
""")
|
||||
#endif
|
||||
return .loaded
|
||||
@@ -93,8 +85,6 @@ public struct AppGroupPersistor {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
let store = AppGroupStore()
|
||||
state.engineMode = store.engineMode
|
||||
state.localASRBackend = store.localASRBackend
|
||||
state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled
|
||||
let shouldProtectTranslation = protectTranslationUntil.map { Date() < $0 } ?? false
|
||||
if !shouldProtectTranslation {
|
||||
state.translationTargetLocaleId = store.translationTargetLocaleId
|
||||
@@ -105,11 +95,6 @@ public struct AppGroupPersistor {
|
||||
state.micDisabledHint = store.isCloudAPIKeyMissingForVoiceInput
|
||||
? 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.
|
||||
@@ -130,12 +115,6 @@ public struct AppGroupPersistor {
|
||||
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"`,
|
||||
/// `"ja"`, or `TranslationLanguageCatalog.offLocaleId`). The
|
||||
/// chip / picker call this through `KeyboardState.setTranslationTargetLocaleId`.
|
||||
@@ -149,4 +128,4 @@ public struct AppGroupPersistor {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,412 @@
|
||||
// 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
|
||||
private var lastFlowAutoStartAttempt: TimeInterval = 0
|
||||
private static let flowAutoStartCooldown: TimeInterval = 20
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func startSessionMonitor() {
|
||||
flowSessionMonitorTask?.cancel()
|
||||
flowSessionMonitorTask = Task { @MainActor [weak self] in
|
||||
while !Task.isCancelled {
|
||||
self?.refreshSessionState()
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if !active {
|
||||
maybeAutoStartFlowSession()
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
if path == "startflow", isPendingFlowStart {
|
||||
state.lastTranscript = ExtL10n.string("keyboard.flow.manualOpenHost")
|
||||
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 maybeAutoStartFlowSession() {
|
||||
guard !FlowSessionBridge.isSessionActive() else { return }
|
||||
guard !isPendingFlowStart, !isFlowRecording, !isAwaitingFlowResult else { return }
|
||||
guard hasFullAccess(), AppGroup.isAvailable else { return }
|
||||
guard case .idle = state.phase else { return }
|
||||
|
||||
let now = Date().timeIntervalSince1970
|
||||
guard now - lastFlowAutoStartAttempt >= Self.flowAutoStartCooldown else { return }
|
||||
lastFlowAutoStartAttempt = now
|
||||
beginFlowStart()
|
||||
}
|
||||
|
||||
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 = ""
|
||||
state.phase = .idle
|
||||
refreshSessionState()
|
||||
debug("completeFlowStartHandoff")
|
||||
}
|
||||
|
||||
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,9 +141,6 @@ public struct KeyboardRootView: View {
|
||||
flowSessionActive: state.flowSessionActive,
|
||||
micDisabled: state.micDisabled,
|
||||
micDisabledHint: state.micDisabledHint,
|
||||
isLocalEngine: state.isLocalEngine,
|
||||
localModelsReady: state.localModelsReady,
|
||||
localModelsLoaded: state.localModelsLoaded,
|
||||
cursorDragHintActive: state.cursorDragActive,
|
||||
openSettings: state.openSettings,
|
||||
startFlowSession: state.startFlowSession
|
||||
@@ -337,9 +334,6 @@ private struct TranscriptLine: View {
|
||||
let flowSessionActive: Bool
|
||||
let micDisabled: Bool
|
||||
let micDisabledHint: String
|
||||
let isLocalEngine: Bool
|
||||
let localModelsReady: Bool
|
||||
let localModelsLoaded: Bool
|
||||
let cursorDragHintActive: Bool
|
||||
let openSettings: () -> Void
|
||||
let startFlowSession: () -> Void
|
||||
@@ -367,22 +361,6 @@ private struct TranscriptLine: View {
|
||||
.foregroundStyle(palette.warning)
|
||||
.lineLimit(1)
|
||||
.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 {
|
||||
ExtL10n.text("keyboard.placeholder.idle")
|
||||
.font(TypeStyle.caption)
|
||||
|
||||
@@ -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()
|
||||
XCTAssertEqual(s.mode, .polish)
|
||||
s.mode = .off
|
||||
XCTAssertEqual(s.mode, .off)
|
||||
s.mode = .transcribe
|
||||
XCTAssertEqual(s.mode, .transcribe)
|
||||
s.mode = .polish
|
||||
XCTAssertEqual(s.mode, .polish)
|
||||
s.phase = .error(.manualOpenRequired, message: "open app")
|
||||
if case .error(.manualOpenRequired, let msg) = s.phase {
|
||||
XCTAssertEqual(msg, "open app")
|
||||
} else {
|
||||
XCTFail("expected manualOpenRequired")
|
||||
}
|
||||
|
||||
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() {
|
||||
// The mode is persisted by rawValue (see `AppGroupStore.setModeId`)
|
||||
// so the round-trip is part of the public contract.
|
||||
for mode in KeyboardState.InputMode.allCases {
|
||||
let raw = mode.rawValue
|
||||
XCTAssertNotNil(KeyboardState.InputMode(rawValue: raw))
|
||||
}
|
||||
func testInputModeIsPolishOnly() {
|
||||
let s = KeyboardState()
|
||||
XCTAssertEqual(s.mode, .polish)
|
||||
XCTAssertEqual(KeyboardState.InputMode.allCases, [.polish])
|
||||
XCTAssertEqual(KeyboardState.InputMode(rawValue: "polish"), .polish)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,15 @@ public enum AppGroup {
|
||||
) != 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.
|
||||
///
|
||||
/// 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
|
||||
/// this is meant to prevent.
|
||||
///
|
||||
/// In release builds we keep the soft fallback + `NSLog` so an
|
||||
/// end-user whose developer account simply lacks the App Group still
|
||||
/// gets a usable main App (the keyboard extension won't work, but at
|
||||
/// least the App doesn't crash on launch).
|
||||
/// In Release builds there is **no** `.standard` fallback — use
|
||||
/// `defaultsIfAvailable` and handle `nil` when provisioning is missing.
|
||||
public static var defaults: UserDefaults {
|
||||
if let d = UserDefaults(suiteName: identifier) {
|
||||
return d
|
||||
guard let suite = defaultsIfAvailable else {
|
||||
#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
|
||||
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
|
||||
return suite
|
||||
}
|
||||
}
|
||||
|
||||
+22
-33
@@ -1,32 +1,30 @@
|
||||
// RecordButton.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Tap-to-toggle mic: tap once to start, tap again to stop. Shows a
|
||||
// remaining-time countdown while recording; last 10 seconds turn red.
|
||||
// Tap-to-toggle mic button shared between the keyboard extension and
|
||||
// host-app keyboard preview surfaces.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct RecordButton: View {
|
||||
public struct RecordButton: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
enum Phase: Equatable {
|
||||
public enum Phase: Equatable {
|
||||
case idle
|
||||
case recording
|
||||
case processing
|
||||
case error
|
||||
}
|
||||
|
||||
let phase: Phase
|
||||
let level: Double // 0...1
|
||||
/// Seconds left in the current utterance; shown only while recording.
|
||||
let remainingSeconds: Int?
|
||||
let isEnabled: Bool
|
||||
let onToggle: () -> Void
|
||||
public let phase: Phase
|
||||
public let level: Double
|
||||
public let remainingSeconds: Int?
|
||||
public let isEnabled: Bool
|
||||
public let onToggle: () -> Void
|
||||
|
||||
@State private var breath: Bool = false
|
||||
@State private var breath = false
|
||||
|
||||
init(
|
||||
public init(
|
||||
phase: Phase,
|
||||
level: Double,
|
||||
remainingSeconds: Int? = nil,
|
||||
@@ -45,8 +43,6 @@ struct RecordButton: View {
|
||||
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 {
|
||||
static let disc: CGFloat = 95
|
||||
static let outerRing: CGFloat = 106
|
||||
@@ -54,7 +50,7 @@ struct RecordButton: View {
|
||||
static let glow: CGFloat = 119
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
public var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.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)
|
||||
|
||||
Circle()
|
||||
.stroke(
|
||||
Color.white.opacity(phase == .idle ? 0.08 : 0.12),
|
||||
lineWidth: 0.5
|
||||
)
|
||||
.stroke(Color.white.opacity(phase == .idle ? 0.08 : 0.12), lineWidth: 0.5)
|
||||
.frame(width: Layout.outerRing, height: Layout.outerRing)
|
||||
|
||||
ZStack {
|
||||
@@ -106,7 +99,6 @@ struct RecordButton: View {
|
||||
.foregroundStyle(.white)
|
||||
.monospacedDigit()
|
||||
.contentTransition(.numericText())
|
||||
// 倒计时略下移,与波形一起在圆盘内更居中。
|
||||
.offset(y: 3)
|
||||
}
|
||||
WaveformView(
|
||||
@@ -114,9 +106,9 @@ struct RecordButton: View {
|
||||
color: Color(red: 1.0, green: 0.78, blue: 0.78),
|
||||
active: true
|
||||
)
|
||||
.frame(width: 73, height: 32)
|
||||
.opacity(0.4)
|
||||
.scaleEffect(0.96)
|
||||
.frame(width: 73, height: 32)
|
||||
.opacity(0.4)
|
||||
.scaleEffect(0.96)
|
||||
}
|
||||
.transition(.opacity)
|
||||
case .processing:
|
||||
@@ -145,13 +137,13 @@ struct RecordButton: View {
|
||||
.onChange(of: phase) { _, new in
|
||||
breath = (new == .recording)
|
||||
}
|
||||
.accessibilityLabel(ExtL10n.text("keyboard.tapToTalkA11y"))
|
||||
.accessibilityLabel(Text(SharedL10n.string("keyboard.tapToTalkA11y")))
|
||||
}
|
||||
|
||||
private func formatRemaining(_ seconds: Int) -> String {
|
||||
let m = seconds / 60
|
||||
let s = seconds % 60
|
||||
return String(format: "%d:%02d", m, s)
|
||||
let minutes = seconds / 60
|
||||
let remainder = seconds % 60
|
||||
return String(format: "%d:%02d", minutes, remainder)
|
||||
}
|
||||
|
||||
private var discGradient: LinearGradient {
|
||||
@@ -175,10 +167,7 @@ struct RecordButton: View {
|
||||
)
|
||||
case .idle:
|
||||
return LinearGradient(
|
||||
colors: [
|
||||
palette.accent.opacity(0.95),
|
||||
palette.accent.opacity(0.75)
|
||||
],
|
||||
colors: [palette.accent.opacity(0.95), palette.accent.opacity(0.75)],
|
||||
startPoint: .top,
|
||||
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
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Symmetric, real-time driven waveform. 18 bars centred around a vertical
|
||||
// axis. The dominant bar is driven by the current RMS; surrounding bars
|
||||
// decay on a small position-based curve so the visual feels like a
|
||||
// horizontal speaker cone, not random noise.
|
||||
// Symmetric, real-time driven waveform. Shared between the keyboard
|
||||
// extension and any host-app preview that mirrors the mic UI.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct WaveformView: View {
|
||||
public struct WaveformView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
let level: Double // 0...1, smoothed RMS
|
||||
let barCount: Int
|
||||
let color: Color?
|
||||
let active: Bool // when false, bars collapse to a thin resting line
|
||||
public let level: Double
|
||||
public let barCount: Int
|
||||
public let color: Color?
|
||||
public let active: Bool
|
||||
|
||||
init(
|
||||
public init(
|
||||
level: Double,
|
||||
barCount: Int = 18,
|
||||
color: Color? = nil,
|
||||
@@ -33,13 +30,16 @@ struct WaveformView: View {
|
||||
color ?? palette.recordRed
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
public var body: some View {
|
||||
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
|
||||
HStack(alignment: .center, spacing: 3) {
|
||||
ForEach(0..<barCount, id: \.self) { i in
|
||||
ForEach(0..<barCount, id: \.self) { index in
|
||||
Capsule()
|
||||
.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)
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,6 @@ struct WaveformView: View {
|
||||
guard active else { return 4 }
|
||||
let centre = Double(barCount - 1) / 2.0
|
||||
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 wobble = 0.18 * phase
|
||||
let magnitude = max(0, min(1, Double(level) + wobble))
|
||||
@@ -0,0 +1,262 @@
|
||||
// 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"
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// 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)
|
||||
)
|
||||
|
||||
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)
|
||||
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,
|
||||
providerId: String,
|
||||
model: String,
|
||||
localASRBackend: LocalASRBackend = .speechAnalyzer,
|
||||
language: AppUILanguage? = nil
|
||||
) -> String {
|
||||
let lang = language ?? AppGroupStore().uiLanguage
|
||||
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)
|
||||
}
|
||||
let providerName = ProviderDisplayName.name(for: providerId, language: lang)
|
||||
@@ -30,14 +29,4 @@ public enum EngineServiceLabel {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
import Foundation
|
||||
|
||||
public struct FlowUtteranceChunkConfig: Sendable, Equatable {
|
||||
/// Target maximum duration per ASR chunk.
|
||||
public let maxChunkDurationSeconds: TimeInterval
|
||||
/// Target duration for the first ASR chunk (starts pipelining early).
|
||||
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.
|
||||
public let overlapDurationSeconds: TimeInterval
|
||||
/// 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 init(
|
||||
maxChunkDurationSeconds: TimeInterval,
|
||||
firstChunkDurationSeconds: TimeInterval = 2.5,
|
||||
subsequentChunkDurationSeconds: TimeInterval = 5.0,
|
||||
overlapDurationSeconds: TimeInterval,
|
||||
pauseExtensionMaxSeconds: TimeInterval,
|
||||
pauseRMSThreshold: Float,
|
||||
sampleRate: Int
|
||||
) {
|
||||
self.maxChunkDurationSeconds = maxChunkDurationSeconds
|
||||
self.firstChunkDurationSeconds = firstChunkDurationSeconds
|
||||
self.subsequentChunkDurationSeconds = subsequentChunkDurationSeconds
|
||||
self.overlapDurationSeconds = overlapDurationSeconds
|
||||
self.pauseExtensionMaxSeconds = pauseExtensionMaxSeconds
|
||||
self.pauseRMSThreshold = pauseRMSThreshold
|
||||
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 {
|
||||
Int(maxChunkDurationSeconds * Double(sampleRate))
|
||||
maxChunkSamples(forChunkIndex: 1)
|
||||
}
|
||||
|
||||
public var overlapSamples: Int {
|
||||
@@ -44,7 +77,8 @@ public struct FlowUtteranceChunkConfig: Sendable, Equatable {
|
||||
|
||||
/// Default for keyboard Flow utterances (≤ 3 min, pipelined ASR).
|
||||
public static let flowDefault = FlowUtteranceChunkConfig(
|
||||
maxChunkDurationSeconds: 30,
|
||||
firstChunkDurationSeconds: 2.5,
|
||||
subsequentChunkDurationSeconds: 5.0,
|
||||
overlapDurationSeconds: 0.5,
|
||||
pauseExtensionMaxSeconds: 2,
|
||||
pauseRMSThreshold: 0.015,
|
||||
|
||||
@@ -82,6 +82,14 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
||||
apiKeyURL: URL(string: "https://platform.moonshot.cn/console/api-keys"),
|
||||
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(
|
||||
id: "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.
|
||||
public static func inferCategory(for term: String) -> Category {
|
||||
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
|
||||
CharacterSet.letters.contains(scalar) && scalar.isASCII
|
||||
}
|
||||
|
||||
@@ -15,144 +15,108 @@ import Combine
|
||||
public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
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 {
|
||||
didSet {
|
||||
defaults.set(providerId, forKey: Key.providerId)
|
||||
// Keep API keys isolated per provider: switching provider in
|
||||
// Settings loads that provider's key instead of reusing the
|
||||
// previously selected vendor's key.
|
||||
guard !isApplyingConfiguration, providerId != configuration.providerId else { return }
|
||||
configuration.providerId = providerId
|
||||
isSyncingProviderAPIKey = true
|
||||
apiKey = Keychain.apiKey(for: providerId) ?? ""
|
||||
apiKey = configuration.apiKey
|
||||
isSyncingProviderAPIKey = false
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
@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 {
|
||||
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 }
|
||||
do {
|
||||
try Keychain.setAPIKey(apiKey, for: providerId)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("⚠️ [OSGKeyboard] Keychain write failed: \(error)")
|
||||
#endif
|
||||
OSGLog.config.warning("Keychain write failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@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 {
|
||||
didSet { defaults.set(modeId, forKey: Key.modeId) }
|
||||
didSet {
|
||||
guard !isApplyingConfiguration, modeId != configuration.modeId else { return }
|
||||
configuration.modeId = modeId
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
@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.
|
||||
/// "cloud" → on-device ASR + user's cloud LLM polish.
|
||||
@Published public var engineMode: String {
|
||||
didSet {
|
||||
defaults.set(engineMode, forKey: Key.engineMode)
|
||||
guard !isApplyingConfiguration, engineMode != configuration.engineMode else { return }
|
||||
configuration.engineMode = engineMode
|
||||
applyEngineModeSideEffects()
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
@Published public var hasCompletedOnboarding: Bool {
|
||||
didSet {
|
||||
defaults.set(hasCompletedOnboarding, forKey: Key.hasCompletedOnboarding)
|
||||
guard !isApplyingConfiguration,
|
||||
hasCompletedOnboarding != configuration.hasCompletedOnboarding else { return }
|
||||
configuration.hasCompletedOnboarding = hasCompletedOnboarding
|
||||
if hasCompletedOnboarding {
|
||||
configuration.onboardingPage = 0
|
||||
onboardingPage = 0
|
||||
}
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
/// Persisted onboarding step so returning from Settings does not reset progress.
|
||||
@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.
|
||||
@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 {
|
||||
defaults.set(localModeCloudPolishEnabled, forKey: Key.localModeCloudPolishEnabled)
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
guard !isApplyingConfiguration,
|
||||
hasAcknowledgedCloudSharing != configuration.hasAcknowledgedCloudSharing else { return }
|
||||
configuration.hasAcknowledgedCloudSharing = hasAcknowledgedCloudSharing
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
/// Host-app UI language. Also mirrored to the App Group for the keyboard extension.
|
||||
@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
|
||||
/// `translationTargetLocaleId` before insertion. **Derived** —
|
||||
/// translation is on iff the user has selected a target locale
|
||||
/// (i.e. the persisted id is anything other than
|
||||
/// `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 {
|
||||
translationTargetLocaleId != TranslationLanguageCatalog.offLocaleId
|
||||
configuration.translationEnabled
|
||||
}
|
||||
/// v0.2.1: BCP-47-ish target language id (e.g. `en`, `ja`, `ko`) the
|
||||
/// 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).
|
||||
@Published public var translationTargetLocaleId: String {
|
||||
didSet {
|
||||
defaults.set(translationTargetLocaleId, forKey: Key.translationTargetLocaleId)
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
guard !isApplyingConfiguration,
|
||||
translationTargetLocaleId != configuration.translationTargetLocaleId else { return }
|
||||
configuration.translationTargetLocaleId = translationTargetLocaleId
|
||||
persistConfiguration(postConfigChanged: true)
|
||||
}
|
||||
}
|
||||
/// Which hand the user holds the phone with — mirrors to the keyboard
|
||||
/// extension so delete / return can swap on the bottom row.
|
||||
@Published public var handednessPreference: HandednessPreference {
|
||||
didSet {
|
||||
defaults.set(handednessPreference.rawValue, forKey: Key.handednessPreference)
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
guard !isApplyingConfiguration,
|
||||
handednessPreference != configuration.handednessPreference else { return }
|
||||
configuration.handednessPreference = handednessPreference
|
||||
persistConfiguration(postConfigChanged: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Press-and-drag pads beside the mic for four-way caret movement.
|
||||
@Published public var cursorDragNavigationEnabled: Bool {
|
||||
didSet {
|
||||
defaults.set(cursorDragNavigationEnabled, forKey: Key.cursorDragNavigationEnabled)
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
guard !isApplyingConfiguration,
|
||||
cursorDragNavigationEnabled != configuration.cursorDragNavigationEnabled else { return }
|
||||
configuration.cursorDragNavigationEnabled = cursorDragNavigationEnabled
|
||||
persistConfiguration(postConfigChanged: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the pipeline should run translate-and-polish (not just
|
||||
/// polish). Both engines honour the selected target locale.
|
||||
public var isTranslationEffective: Bool {
|
||||
translationEnabled
|
||||
configuration.isTranslationEffective
|
||||
}
|
||||
|
||||
/// Translation picker visibility — available on both engines.
|
||||
@@ -194,21 +164,22 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
/// v0.3.0: how aggressively the LLM should rewrite the ASR
|
||||
/// transcript. Default is `medium` (Typeless-equivalent).
|
||||
@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()
|
||||
}
|
||||
}
|
||||
|
||||
public var isConfigured: Bool {
|
||||
// Local engine (on-device ASR only) doesn't need an API key,
|
||||
// base URL, or model — the LLM round-trip is skipped entirely.
|
||||
// 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.
|
||||
// Local engine uses on-device ASR + built-in DeepSeek polish and
|
||||
// does not need a user API key. Cloud needs base URL, key, and model.
|
||||
if isLocalEngine { return true }
|
||||
return !baseURL.isEmpty && !apiKey.isEmpty && !model.isEmpty
|
||||
}
|
||||
|
||||
/// 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.
|
||||
public var shouldPolishLocalTranscript: Bool { isLocalEngine }
|
||||
@@ -217,83 +188,37 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
public var localModeProviderId: String { "deepseek" }
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private var configuration: AppGroupConfiguration
|
||||
private var isApplyingConfiguration = false
|
||||
private var isSyncingProviderAPIKey = false
|
||||
|
||||
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
|
||||
let pid = resolvedDefaults.string(forKey: Key.providerId) ?? "openai"
|
||||
let preset = LLMProvider.provider(id: pid)
|
||||
self.providerId = pid
|
||||
self.baseURL = resolvedDefaults.string(forKey: Key.baseURL) ?? preset.defaultBaseURL
|
||||
self.configuration = AppGroupConfiguration.load(fromAvailable: resolvedDefaults)
|
||||
|
||||
// Resolve the API key with a one-shot migration from the legacy
|
||||
// UserDefaults slot. After this runs once, `Key.apiKeyLegacy`
|
||||
// is empty in the suite and all subsequent reads go through the
|
||||
// Keychain.
|
||||
self.apiKey = ProviderConfig.resolveAPIKey(defaults: resolvedDefaults, providerId: pid)
|
||||
|
||||
self.model = resolvedDefaults.string(forKey: Key.model) ?? preset.defaultModel
|
||||
self.modeId = resolvedDefaults.string(forKey: Key.modeId) ?? "polish"
|
||||
self.localeId = resolvedDefaults.string(forKey: Key.localeId) ?? "auto"
|
||||
self.engineMode = resolvedDefaults.string(forKey: Key.engineMode) ?? "cloud"
|
||||
self.hasCompletedOnboarding = resolvedDefaults.bool(forKey: Key.hasCompletedOnboarding)
|
||||
let savedPage = resolvedDefaults.integer(forKey: Key.onboardingPage)
|
||||
self.onboardingPage = savedPage > 0 ? savedPage : 0
|
||||
self.hasAcknowledgedCloudSharing = resolvedDefaults.bool(forKey: Key.hasAcknowledgedCloudSharing)
|
||||
// Tolerate missing / unknown raw values (e.g. an enum case that
|
||||
// was renamed in a later build) by falling back to the default
|
||||
// rather than crashing inside `RawRepresentable.init`.
|
||||
let rawBackend = resolvedDefaults.string(forKey: Key.localASRBackend) ?? LocalASRBackend.speechAnalyzer.rawValue
|
||||
self.localASRBackend = LocalASRBackend(rawValue: rawBackend) ?? .speechAnalyzer
|
||||
// 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"))
|
||||
}
|
||||
isApplyingConfiguration = true
|
||||
providerId = configuration.providerId
|
||||
baseURL = configuration.baseURL
|
||||
apiKey = configuration.apiKey
|
||||
model = configuration.model
|
||||
modeId = configuration.modeId
|
||||
localeId = configuration.localeId
|
||||
engineMode = configuration.engineMode
|
||||
hasCompletedOnboarding = configuration.hasCompletedOnboarding
|
||||
onboardingPage = configuration.onboardingPage
|
||||
hasAcknowledgedCloudSharing = configuration.hasAcknowledgedCloudSharing
|
||||
uiLanguage = configuration.uiLanguage
|
||||
translationTargetLocaleId = configuration.translationTargetLocaleId
|
||||
handednessPreference = configuration.handednessPreference
|
||||
cursorDragNavigationEnabled = configuration.cursorDragNavigationEnabled
|
||||
polishIntensity = configuration.polishIntensity
|
||||
isApplyingConfiguration = false
|
||||
}
|
||||
|
||||
/// Keep cloud vs local provider choices isolated when the user
|
||||
@@ -304,29 +229,15 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the API key from the Keychain, falling back to a one-time
|
||||
/// migration from the legacy UserDefaults slot.
|
||||
private static func resolveAPIKey(defaults: UserDefaults, providerId: String) -> String {
|
||||
if let stored = Keychain.apiKey(for: providerId), !stored.isEmpty {
|
||||
return stored
|
||||
private func persistConfiguration(postConfigChanged: Bool = false) {
|
||||
configuration.save(to: defaults)
|
||||
if postConfigChanged {
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
// 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) {
|
||||
isApplyingConfiguration = true
|
||||
providerId = preset.id
|
||||
if !preset.defaultBaseURL.isEmpty {
|
||||
baseURL = preset.defaultBaseURL
|
||||
@@ -334,15 +245,31 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
if !preset.defaultModel.isEmpty {
|
||||
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() {
|
||||
providerId = "openai"
|
||||
isApplyingConfiguration = true
|
||||
let preset = LLMProvider.provider(id: "openai")
|
||||
providerId = preset.id
|
||||
baseURL = preset.defaultBaseURL
|
||||
apiKey = ""
|
||||
model = preset.defaultModel
|
||||
handednessPreference = .left
|
||||
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.
|
||||
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:)`.
|
||||
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult
|
||||
}
|
||||
@@ -60,6 +63,8 @@ public enum ASRChunkResult: Sendable, Equatable {
|
||||
extension ASRService {
|
||||
public func resetForNewUtterance() {}
|
||||
|
||||
public func warmup(locale: Locale) async {}
|
||||
|
||||
public func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
|
||||
guard !samples.isEmpty else { return .success("") }
|
||||
if Task.isCancelled { return .cancelled }
|
||||
@@ -116,27 +121,7 @@ public enum ASREvent: Sendable, Equatable {
|
||||
// MARK: - Factory
|
||||
|
||||
public enum ASRServiceFactory {
|
||||
/// Returns the on-device ASR backend. As of v0.2.0 the only
|
||||
/// 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.
|
||||
/// Returns the on-device `SpeechAnalyzer` + `DictationTranscriber` backend.
|
||||
public static func make() -> ASRService {
|
||||
SpeechAnalyzerASR()
|
||||
}
|
||||
@@ -197,12 +182,52 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable {
|
||||
private var chunkAnalyzerFormat: AVAudioFormat?
|
||||
|
||||
func resetForNewUtterance() {
|
||||
// Keep chunk format / asset cache warm across utterances in one Flow session.
|
||||
}
|
||||
|
||||
func invalidateChunkPreparationCache() {
|
||||
lock.withLock {
|
||||
chunkPreparedLocaleID = 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 {
|
||||
guard !samples.isEmpty else { return .success("") }
|
||||
if Task.isCancelled { return .cancelled }
|
||||
@@ -228,9 +253,12 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable {
|
||||
throw ASRChunkError.localeUnsupported
|
||||
}
|
||||
let localeID = resolvedLocale.identifier(.bcp47)
|
||||
let transcriber = DictationTranscriber(
|
||||
let lmConfiguration = CustomLanguageModelManager.shared.configurationForTranscription(
|
||||
locale: resolvedLocale
|
||||
)
|
||||
let transcriber = CustomLanguageModelManager.makeDictationTranscriber(
|
||||
locale: resolvedLocale,
|
||||
preset: .progressiveLongDictation
|
||||
lmConfiguration: lmConfiguration
|
||||
)
|
||||
|
||||
let analyzerFormat: AVAudioFormat
|
||||
@@ -337,9 +365,12 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable {
|
||||
}
|
||||
// Each pipelined chunk is ≤ 30 s; long dictation preset keeps a
|
||||
// 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,
|
||||
preset: .progressiveLongDictation
|
||||
lmConfiguration: lmConfiguration
|
||||
)
|
||||
do {
|
||||
try await Self.prepareAssetsIfNeeded(for: transcriber, locale: resolvedLocale)
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
// AppGroupStore.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Convenience wrapper around App Group UserDefaults for non-Published reads.
|
||||
// Used by the keyboard extension (no SwiftUI) to read config without
|
||||
// instantiating an ObservableObject.
|
||||
// Thin read/write facade over `AppGroupConfiguration` for the keyboard
|
||||
// extension (no SwiftUI) and other non-ObservableObject call sites.
|
||||
//
|
||||
// `apiKey` is NOT read from UserDefaults — see `Keychain.swift`. We
|
||||
// share access between the host app and the keyboard extension via a
|
||||
// shared keychain-access-group declared in both targets' entitlements.
|
||||
// `apiKey` is NOT stored in UserDefaults — see `Keychain.swift`.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -19,340 +16,150 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
self.defaults = defaults
|
||||
return
|
||||
}
|
||||
// Never hard-crash on implicit construction sites (e.g. default
|
||||
// service initializers). If App Group is unavailable, use .standard
|
||||
// so callers can still surface a user-facing setup error.
|
||||
self.defaults = 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
|
||||
// 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 {
|
||||
static let providerId = "config.providerId"
|
||||
static let baseURL = "config.baseURL"
|
||||
static let model = "config.model"
|
||||
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"
|
||||
private func mutateConfiguration(_ transform: (inout AppGroupConfiguration) -> Void) {
|
||||
var config = AppGroupConfiguration.load(fromAvailable: defaults)
|
||||
transform(&config)
|
||||
config.save(to: defaults)
|
||||
}
|
||||
|
||||
// MARK: - Reads
|
||||
|
||||
public var providerId: String {
|
||||
defaults.string(forKey: Key.providerId) ?? "openai"
|
||||
}
|
||||
|
||||
public var baseURL: String {
|
||||
defaults.string(forKey: Key.baseURL) ?? LLMProvider.provider(id: providerId).defaultBaseURL
|
||||
}
|
||||
|
||||
/// API key lives in the Keychain (cross-process, encrypted at rest).
|
||||
/// Returns "" when nothing is stored so the LLMClient can surface a
|
||||
/// `noAPIKey` error rather than firing off an obviously-bad request.
|
||||
public var apiKey: String {
|
||||
Keychain.apiKey(for: providerId) ?? ""
|
||||
}
|
||||
|
||||
public var model: String {
|
||||
defaults.string(forKey: Key.model) ?? LLMProvider.provider(id: providerId).defaultModel
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
public var providerId: String { configuration.providerId }
|
||||
public var baseURL: String { configuration.baseURL }
|
||||
public var apiKey: String { configuration.apiKey }
|
||||
public var model: String { configuration.model }
|
||||
public var modeId: String { configuration.modeId }
|
||||
public var localeId: String { configuration.localeId }
|
||||
public var engineMode: String { configuration.engineMode }
|
||||
public var uiLanguage: AppUILanguage { configuration.uiLanguage }
|
||||
public var translationEnabled: Bool { configuration.translationEnabled }
|
||||
public var translationTargetLocaleId: String { configuration.translationTargetLocaleId }
|
||||
public var handednessPreference: HandednessPreference { configuration.handednessPreference }
|
||||
public var cursorDragNavigationEnabled: Bool { configuration.cursorDragNavigationEnabled }
|
||||
public var polishIntensity: PolishIntensity { configuration.polishIntensity }
|
||||
public var isTranslationEffective: Bool { configuration.isTranslationEffective }
|
||||
public var isLocalEngine: Bool { configuration.isLocalEngine }
|
||||
public var polishModeForPipeline: PolishingService.PolishMode { configuration.polishModeForPipeline }
|
||||
public var polishProviderIdOverride: String? { configuration.polishProviderIdOverride }
|
||||
public var isCloudAPIKeyMissingForVoiceInput: Bool { configuration.isCloudAPIKeyMissingForVoiceInput }
|
||||
|
||||
/// Whether the keyboard top-bar translation chip should render.
|
||||
public var isTranslationChipVisible: Bool { true }
|
||||
|
||||
/// Cloud engine requires a provider-specific API key before the user
|
||||
/// can start voice input. Local engine uses the built-in DeepSeek path.
|
||||
public var isCloudAPIKeyMissingForVoiceInput: Bool {
|
||||
guard engineMode == "cloud" else { return false }
|
||||
return apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
// MARK: - Writes
|
||||
|
||||
public func setModeId(_ id: String) {
|
||||
mutateConfiguration { $0.modeId = id }
|
||||
}
|
||||
|
||||
/// Polish vs translate-and-polish for the active pipeline.
|
||||
public var polishModeForPipeline: PolishingService.PolishMode {
|
||||
isTranslationEffective
|
||||
? .translate(targetLocaleId: translationTargetLocaleId)
|
||||
: .polish
|
||||
public func setLocaleId(_ id: String) {
|
||||
mutateConfiguration { $0.localeId = id }
|
||||
}
|
||||
|
||||
/// Local engine pins the LLM step to DeepSeek; cloud uses the
|
||||
/// user's configured provider.
|
||||
public var polishProviderIdOverride: String? {
|
||||
engineMode == "local" ? "deepseek" : nil
|
||||
}
|
||||
|
||||
// MARK: - Polish settings (v0.3.0+)
|
||||
|
||||
/// 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
|
||||
public func setEngineMode(_ mode: String) {
|
||||
mutateConfiguration { config in
|
||||
config.engineMode = mode
|
||||
if mode == "cloud", config.providerId == "deepseek" {
|
||||
let openAI = LLMProvider.provider(id: "openai")
|
||||
config.providerId = openAI.id
|
||||
config.baseURL = openAI.defaultBaseURL
|
||||
config.model = openAI.defaultModel
|
||||
}
|
||||
}
|
||||
let resolved = PolishIntensity.resolve(storedRawValue: raw)
|
||||
if raw == PolishIntensity.legacyOffRawValue {
|
||||
defaults.set(resolved.rawValue, forKey: Key.polishIntensity)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
public func setUILanguage(_ language: AppUILanguage) {
|
||||
mutateConfiguration { $0.uiLanguage = language }
|
||||
}
|
||||
|
||||
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) {
|
||||
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 {
|
||||
get { defaults.bool(forKey: "config.hasCompletedOnboarding") }
|
||||
set { defaults.set(newValue, forKey: "config.hasCompletedOnboarding") }
|
||||
get { configuration.hasCompletedOnboarding }
|
||||
set { setHasCompletedOnboarding(newValue) }
|
||||
}
|
||||
|
||||
public var onboardingPage: Int {
|
||||
get { defaults.integer(forKey: "config.onboardingPage") }
|
||||
set { defaults.set(newValue, forKey: "config.onboardingPage") }
|
||||
get { configuration.onboardingPage }
|
||||
set { setOnboardingPage(newValue) }
|
||||
}
|
||||
|
||||
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) {
|
||||
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)? {
|
||||
guard let raw = defaults.string(forKey: Key.detectedAppContext),
|
||||
let value = AppContext(rawValue: raw)
|
||||
else { return nil }
|
||||
let timestamp = defaults.object(forKey: Key.detectedAppContextAt) as? Date ?? .distantPast
|
||||
return (value, timestamp)
|
||||
configuration.detectedAppContext(from: defaults)
|
||||
}
|
||||
|
||||
public func setDetectedAppContext(_ context: AppContext, at date: Date = Date()) {
|
||||
defaults.set(context.rawValue, forKey: Key.detectedAppContext)
|
||||
defaults.set(date, forKey: Key.detectedAppContextAt)
|
||||
var config = configuration
|
||||
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 {
|
||||
get {
|
||||
guard let data = defaults.data(forKey: Key.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: Key.personalDictionary)
|
||||
}
|
||||
}
|
||||
return dictionary
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("⚠️ [AppGroupStore] personalDictionary decode failed: \(error)")
|
||||
#endif
|
||||
return .empty
|
||||
}
|
||||
}
|
||||
set {
|
||||
setPersonalDictionary(newValue)
|
||||
}
|
||||
get { configuration.personalDictionary }
|
||||
set { setPersonalDictionary(newValue) }
|
||||
}
|
||||
|
||||
public func setPersonalDictionary(_ dictionary: PersonalDictionary) {
|
||||
do {
|
||||
let data = try JSONEncoder().encode(dictionary)
|
||||
defaults.set(data, forKey: Key.personalDictionary)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("⚠️ [AppGroupStore] personalDictionary encode failed: \(error)")
|
||||
#endif
|
||||
}
|
||||
mutateConfiguration { $0.personalDictionary = dictionary }
|
||||
}
|
||||
|
||||
// MARK: - Client
|
||||
|
||||
public func makeClient() -> LLMClient {
|
||||
OpenAICompatibleClient(
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
model: model
|
||||
)
|
||||
configuration.makeClient()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,31 @@
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// TypeWhisper-style Flow session bridge: keyboard writes recording
|
||||
// signals; host app writes transcription results. Legacy one-shot
|
||||
// dictation handoff remains in `DictationBridge`.
|
||||
// signals; host app writes transcription results.
|
||||
|
||||
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 {
|
||||
private static func resolvedDefaults(_ defaults: UserDefaults?) -> UserDefaults {
|
||||
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.
|
||||
@@ -70,7 +86,6 @@ public enum FlowSessionBridge {
|
||||
/// background while the continuous audio session is frozen.
|
||||
public static func isSessionActive(defaults: UserDefaults? = nil) -> Bool {
|
||||
let store = resolvedDefaults(defaults)
|
||||
flush(store)
|
||||
guard store.bool(forKey: FlowSessionKeys.flowSessionActive) else { return false }
|
||||
|
||||
let expires = store.double(forKey: FlowSessionKeys.flowSessionExpires)
|
||||
@@ -81,7 +96,6 @@ public enum FlowSessionBridge {
|
||||
/// actively processing). Used for auto-start heuristics, not gating record.
|
||||
public static func isHostReachable(defaults: UserDefaults? = nil) -> Bool {
|
||||
let store = resolvedDefaults(defaults)
|
||||
flush(store)
|
||||
guard isSessionActive(defaults: store) else { return false }
|
||||
|
||||
let heartbeat = store.double(forKey: FlowSessionKeys.flowHeartbeat)
|
||||
@@ -144,6 +158,7 @@ public enum FlowSessionBridge {
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.set(trimmed, forKey: FlowSessionKeys.transcriptionResult)
|
||||
store.removeObject(forKey: FlowSessionKeys.transcriptionError)
|
||||
store.removeObject(forKey: FlowSessionKeys.transcriptionPartial)
|
||||
if let polishWarning, !polishWarning.isEmpty {
|
||||
store.set(polishWarning, forKey: FlowSessionKeys.transcriptionPolishWarning)
|
||||
} else {
|
||||
@@ -151,16 +166,46 @@ public enum FlowSessionBridge {
|
||||
}
|
||||
setRecordingState(.idle, defaults: 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(
|
||||
_ message: String,
|
||||
kind: FlowSessionKeys.TranscriptionErrorKind = .generic,
|
||||
defaults: UserDefaults? = nil
|
||||
) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.set(message, forKey: FlowSessionKeys.transcriptionError)
|
||||
store.set(kind.rawValue, forKey: FlowSessionKeys.transcriptionErrorKind)
|
||||
setRecordingState(.idle, defaults: store)
|
||||
flush(store)
|
||||
FlowSessionDarwin.postTranscriptionChanged()
|
||||
}
|
||||
|
||||
/// Returns and clears a pending transcription result, if any.
|
||||
@@ -174,7 +219,6 @@ public enum FlowSessionBridge {
|
||||
defaults: UserDefaults? = nil
|
||||
) -> TranscriptionDelivery? {
|
||||
let store = resolvedDefaults(defaults)
|
||||
flush(store)
|
||||
guard let text = store.string(forKey: FlowSessionKeys.transcriptionResult), !text.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
@@ -186,20 +230,21 @@ public enum FlowSessionBridge {
|
||||
}
|
||||
|
||||
/// 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)
|
||||
flush(store)
|
||||
guard let message = store.string(forKey: FlowSessionKeys.transcriptionError), !message.isEmpty else {
|
||||
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.transcriptionErrorKind)
|
||||
flush(store)
|
||||
return message
|
||||
return FlowTranscriptionError(message: message, kind: kind)
|
||||
}
|
||||
|
||||
public static func audioLevels(defaults: UserDefaults? = nil) -> [Float] {
|
||||
let store = resolvedDefaults(defaults)
|
||||
flush(store)
|
||||
if let levels = store.array(forKey: FlowSessionKeys.audioLevels) as? [Double], !levels.isEmpty {
|
||||
return levels.map { Float($0) }
|
||||
}
|
||||
@@ -240,7 +285,9 @@ public enum FlowSessionBridge {
|
||||
|
||||
private static func clearTranscription(defaults: UserDefaults) {
|
||||
defaults.removeObject(forKey: FlowSessionKeys.transcriptionResult)
|
||||
defaults.removeObject(forKey: FlowSessionKeys.transcriptionPartial)
|
||||
defaults.removeObject(forKey: FlowSessionKeys.transcriptionPolishWarning)
|
||||
defaults.removeObject(forKey: FlowSessionKeys.transcriptionError)
|
||||
defaults.removeObject(forKey: FlowSessionKeys.transcriptionErrorKind)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import Foundation
|
||||
|
||||
public enum FlowSessionDarwin {
|
||||
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() {
|
||||
CFNotificationCenterPostNotification(
|
||||
@@ -18,6 +20,16 @@ public enum FlowSessionDarwin {
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
public static func postTranscriptionChanged() {
|
||||
CFNotificationCenterPostNotification(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
CFNotificationName(transcriptionNotificationName as CFString),
|
||||
nil,
|
||||
nil,
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Observes Darwin notifications on a background thread; invokes
|
||||
|
||||
@@ -13,9 +13,13 @@ public enum FlowSessionKeys {
|
||||
public static let keyboardRecordingState = "flow.keyboardRecordingState"
|
||||
public static let transcriptionLanguage = "flow.transcriptionLanguage"
|
||||
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.
|
||||
public static let transcriptionPolishWarning = "flow.transcriptionPolishWarning"
|
||||
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"
|
||||
|
||||
/// Heartbeat older than this while the host is foreground → likely killed.
|
||||
@@ -36,15 +40,7 @@ public enum FlowSessionKeys {
|
||||
/// Keyboard watchdog after the user stops recording (not utterance max length).
|
||||
/// Must cover worst-case post-stop backlog: remaining SpeechAnalyzer chunks
|
||||
/// plus cloud LLM polish (see `PolishingService.effectiveTimeout` cap).
|
||||
///
|
||||
/// 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 {
|
||||
public static func keyboardResultTimeout(engineMode: String) -> TimeInterval {
|
||||
if engineMode == "local" {
|
||||
return 180
|
||||
}
|
||||
@@ -58,4 +54,13 @@ public enum FlowSessionKeys {
|
||||
case processing
|
||||
case aborted
|
||||
}
|
||||
|
||||
/// Structured host → keyboard transcription failure kind.
|
||||
public enum TranscriptionErrorKind: String, Sendable, Equatable {
|
||||
case noSpeech
|
||||
case recognitionInterrupted
|
||||
case audioUnavailable
|
||||
case asrFailed
|
||||
case generic
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,26 +35,37 @@ public final class KeyboardState: ObservableObject {
|
||||
case asr(String)
|
||||
case llm(LLMError)
|
||||
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)
|
||||
}
|
||||
|
||||
public enum Reason: Equatable { case mic, speech }
|
||||
}
|
||||
|
||||
/// Voice input always runs through polish; legacy off/transcribe modes removed.
|
||||
public enum InputMode: String, CaseIterable, Identifiable {
|
||||
case off
|
||||
case transcribe
|
||||
case polish
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
public var labelKey: String {
|
||||
switch self {
|
||||
case .off: return "mode.off"
|
||||
case .transcribe: return "mode.transcribe"
|
||||
case .polish: return "mode.polish"
|
||||
}
|
||||
}
|
||||
public var labelKey: String { "mode.polish" }
|
||||
}
|
||||
|
||||
@Published public var phase: Phase = .idle
|
||||
@@ -78,20 +89,6 @@ public final class KeyboardState: ObservableObject {
|
||||
@Published public var micDisabledHint: String = ""
|
||||
/// "local" → on-device ASR only. "cloud" → ASR + LLM polish.
|
||||
@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
|
||||
/// locale has been selected (mirrors `ProviderConfig.translationEnabled`
|
||||
/// 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"
|
||||
/// state on first install.
|
||||
@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.
|
||||
@Published public var handednessPreference: HandednessPreference = .left
|
||||
/// 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 setLocale: (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`
|
||||
/// is derived from the locale id, so there's no separate toggle to
|
||||
/// persist. Wired in `KeyboardViewController.installStateActions`.
|
||||
@@ -209,4 +202,20 @@ public final class KeyboardState: ObservableObject {
|
||||
return s
|
||||
}
|
||||
#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:
|
||||
// - `OSGKeyboard/Views/PreviewASRController.swift` (typealias)
|
||||
// - `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`
|
||||
//
|
||||
// Do NOT remove without updating those call sites. The earlier
|
||||
@@ -104,14 +104,7 @@ public final class LiveDictationController: ObservableObject {
|
||||
private var didInstallTap = false
|
||||
|
||||
public init(asr: ASRService? = nil) {
|
||||
// Resolve through the factory so the user's `LocalASRBackend`
|
||||
// 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
|
||||
)
|
||||
self.asr = asr ?? ASRServiceFactory.make()
|
||||
}
|
||||
|
||||
/// 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
|
||||
let task = Task {
|
||||
var buffer: [Float] = []
|
||||
buffer.reserveCapacity(config.maxChunkSamples + config.pauseExtensionSamples)
|
||||
let initialCapacity = config.maxChunkSamples(forChunkIndex: 0) + config.pauseExtensionSamples
|
||||
buffer.reserveCapacity(initialCapacity)
|
||||
var chunkIndex = 0
|
||||
|
||||
func emit(upTo splitEnd: Int, isLast: Bool) {
|
||||
@@ -40,8 +41,12 @@ public enum UtteranceStreamChunker {
|
||||
guard !snap.samples.isEmpty else { continue }
|
||||
buffer.append(contentsOf: snap.samples)
|
||||
|
||||
while buffer.count >= config.maxChunkSamples {
|
||||
let split = pauseAwareSplitIndex(in: buffer, config: config)
|
||||
while buffer.count >= config.maxChunkSamples(forChunkIndex: chunkIndex) {
|
||||
let split = pauseAwareSplitIndex(
|
||||
in: buffer,
|
||||
config: config,
|
||||
chunkIndex: chunkIndex
|
||||
)
|
||||
emit(upTo: split, isLast: false)
|
||||
}
|
||||
}
|
||||
@@ -66,9 +71,10 @@ public enum UtteranceStreamChunker {
|
||||
/// Pick a split index at or after `maxChunkSamples`, preferring a pause.
|
||||
static func pauseAwareSplitIndex(
|
||||
in buffer: [Float],
|
||||
config: FlowUtteranceChunkConfig
|
||||
config: FlowUtteranceChunkConfig,
|
||||
chunkIndex: Int = 1
|
||||
) -> Int {
|
||||
let minSplit = config.maxChunkSamples
|
||||
let minSplit = config.maxChunkSamples(forChunkIndex: chunkIndex)
|
||||
guard buffer.count >= minSplit else { return buffer.count }
|
||||
|
||||
let searchEnd = min(buffer.count, minSplit + config.pauseExtensionSamples)
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"provider.qwen" = "Qwen (DashScope)";
|
||||
"provider.zhipu" = "Zhipu GLM";
|
||||
"provider.moonshot" = "Moonshot";
|
||||
"provider.mimo" = "Xiaomi MiMo";
|
||||
"provider.custom" = "Custom";
|
||||
|
||||
/* LLM errors */
|
||||
@@ -79,3 +80,10 @@
|
||||
"dict.source.history" = "Auto-learned";
|
||||
"dict.source.contacts" = "From Contacts";
|
||||
"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.zhipu" = "智谱 GLM";
|
||||
"provider.moonshot" = "月之暗面";
|
||||
"provider.mimo" = "小米 MiMo";
|
||||
"provider.custom" = "自定义";
|
||||
|
||||
/* LLM errors */
|
||||
@@ -79,3 +80,10 @@
|
||||
"dict.source.history" = "自动学习";
|
||||
"dict.source.contacts" = "来自通讯录";
|
||||
"dict.source.recentEdit" = "来自最近编辑";
|
||||
|
||||
/* 键盘 UI(扩展与预览共用) */
|
||||
"keyboard.tapToTalkA11y" = "点击说话";
|
||||
"keyboard.translation.chip" = "翻译";
|
||||
"keyboard.translation.offMenu" = "不翻译";
|
||||
"keyboard.translation.a11y" = "翻译";
|
||||
"keyboard.translation.a11yHint" = "切换翻译或更改目标语言。";
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// 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)
|
||||
}
|
||||
|
||||
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.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)
|
||||
}
|
||||
|
||||
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.finish()
|
||||
|
||||
var partials: [String] = []
|
||||
let partialsLock = OSAllocatedUnfairLock(initialState: [String]())
|
||||
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 {
|
||||
return XCTFail("expected success, got \(outcome)")
|
||||
@@ -89,8 +90,7 @@ final class ChunkedUtterancePipelineTests: XCTestCase {
|
||||
}
|
||||
|
||||
private struct FailingSecondChunkASR: ASRService, @unchecked Sendable {
|
||||
private let lock = OSAllocatedUnfairLock()
|
||||
private var index = 0
|
||||
private let callIndex = OSAllocatedUnfairLock(initialState: 0)
|
||||
|
||||
func transcribe(
|
||||
stream: AsyncStream<AudioBufferSnapshot>,
|
||||
@@ -103,9 +103,10 @@ private struct FailingSecondChunkASR: ASRService, @unchecked Sendable {
|
||||
|
||||
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult {
|
||||
_ = locale
|
||||
let current = lock.withLock {
|
||||
defer { index += 1 }
|
||||
return index
|
||||
let current = callIndex.withLock { state in
|
||||
let value = state
|
||||
state += 1
|
||||
return value
|
||||
}
|
||||
if current == 1 {
|
||||
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))
|
||||
}
|
||||
|
||||
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() {
|
||||
FlowSessionDarwin.postSessionChanged()
|
||||
}
|
||||
|
||||
@@ -122,10 +122,7 @@ final class IntelligentPolishTests: XCTestCase {
|
||||
|
||||
func testPolishServiceMissingAPIKeyThrows() async {
|
||||
store.setEngineMode("cloud")
|
||||
let service = PolishingService(
|
||||
store: store,
|
||||
client: EchoLLMClient()
|
||||
)
|
||||
let service = PolishingService(store: store)
|
||||
do {
|
||||
_ = try await service.polish("hello world", context: PolishContext(intensity: .medium))
|
||||
XCTFail("Expected missingAPIKey")
|
||||
@@ -218,7 +215,10 @@ final class IntelligentPolishTests: XCTestCase {
|
||||
store.setEngineMode("local")
|
||||
let captured = CapturingLLMClient()
|
||||
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(
|
||||
captured.lastPrompt.contains("全局输出契约"),
|
||||
"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() {
|
||||
store.onboardingPage = 3
|
||||
store.hasCompletedOnboarding = true
|
||||
XCTAssertEqual(store.onboardingPage, 3)
|
||||
store.hasCompletedOnboarding = true
|
||||
XCTAssertTrue(store.hasCompletedOnboarding)
|
||||
// Completing onboarding clears the in-progress page index.
|
||||
XCTAssertEqual(store.onboardingPage, 0)
|
||||
}
|
||||
|
||||
func testOnboardingFlagsSurviveReconstruct() {
|
||||
store.onboardingPage = 4
|
||||
store.hasCompletedOnboarding = true
|
||||
|
||||
// Simulate the keyboard extension being torn down and rebuilt
|
||||
// (which is what happens on every `viewDidLoad` cycle).
|
||||
let store2 = AppGroupStore(defaults: defaults)
|
||||
var store2 = AppGroupStore(defaults: defaults)
|
||||
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
|
||||
|
||||
func testDetectedAppContextRoundTrip() {
|
||||
func testDetectedAppContextRoundTrip() throws {
|
||||
let now = Date()
|
||||
store.setDetectedAppContext(.code, at: now)
|
||||
let result = store.detectedAppContext
|
||||
XCTAssertEqual(result?.context, .code)
|
||||
XCTAssertEqual(result?.observedAt.timeIntervalSinceReferenceDate,
|
||||
let observedAt = try XCTUnwrap(result?.observedAt)
|
||||
XCTAssertEqual(observedAt.timeIntervalSinceReferenceDate,
|
||||
now.timeIntervalSinceReferenceDate,
|
||||
accuracy: 0.001)
|
||||
}
|
||||
|
||||
@@ -237,13 +237,13 @@ final class LLMClientTests: XCTestCase {
|
||||
|
||||
/// Cross-process App Group contract: what `ProviderConfig` writes must
|
||||
/// be readable through `AppGroupStore` on the same suite.
|
||||
func testAppGroupCrossProcessAndOffModeShortCircuit() async {
|
||||
func testAppGroupCrossProcessLegacyOffModeMigratesToPolish() async {
|
||||
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
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)
|
||||
config.apiKey = "sk-test-1234"
|
||||
config.model = "gpt-4o-mini"
|
||||
@@ -254,7 +254,7 @@ final class LLMClientTests: XCTestCase {
|
||||
// same suite.
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -317,20 +317,19 @@ final class LLMClientTests: XCTestCase {
|
||||
XCTAssertEqual(calls, 1, "cloud engine must polish even with legacy modeId=off")
|
||||
}
|
||||
|
||||
/// Local engine is ASR-only and never calls the cloud `LLMClient`.
|
||||
func testPolisherReturnsRawWhenEngineLocal() async throws {
|
||||
/// Local engine always runs the built-in DeepSeek polish step.
|
||||
func testPolisherInvokesLLMWhenEngineLocal() async throws {
|
||||
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
defaults.set("local", forKey: "config.engineMode")
|
||||
defaults.set("off", forKey: "config.modeId")
|
||||
defaults.set("polish", forKey: "config.modeId")
|
||||
|
||||
let counter = CallCounter()
|
||||
let countingClient = CountingLLMClient(counter: counter) { _, _ in
|
||||
XCTFail("cloud LLMClient must not run under local engine")
|
||||
return ""
|
||||
let countingClient = CountingLLMClient(counter: counter) { raw, _ in
|
||||
"POLISHED: \(raw)"
|
||||
}
|
||||
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
@@ -340,10 +339,10 @@ final class LLMClientTests: XCTestCase {
|
||||
timeout: 1
|
||||
)
|
||||
|
||||
let result = try await polisher.polish(" hello ")
|
||||
XCTAssertEqual(result, "hello")
|
||||
let result = try await polisher.polish("hello world")
|
||||
XCTAssertEqual(result, "POLISHED: hello world")
|
||||
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
|
||||
|
||||
@@ -24,6 +24,30 @@ final class UtteranceStreamChunkerTests: XCTestCase {
|
||||
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 {
|
||||
let sampleCount = config.maxChunkSamples * 2 + 100
|
||||
let samples = [Float](repeating: 0.05, count: sampleCount)
|
||||
|
||||
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
|
||||
# -*- 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):
|
||||
1. ASC8384/SogouPopularDict accumulated pinyin TSV
|
||||
2. Local 计算机词汇大全【官方推荐】.scel
|
||||
3. Local 网络流行新词.scel
|
||||
Sources:
|
||||
Local 计算机词汇大全【官方推荐】.scel (IT / computer vocabulary only)
|
||||
|
||||
Casual network slang and Sogou popular-word dumps are intentionally excluded —
|
||||
they dilute custom LM phrase biasing without improving domain ASR accuracy.
|
||||
|
||||
Output:
|
||||
OSGKeyboard/Resources/CustomLanguageModel/v1/phrases.tsv
|
||||
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
|
||||
@@ -17,43 +21,19 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
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]
|
||||
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_NETWORK_SCEL = Path("/Users/rocky/Downloads/网络流行新词.scel")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceSpec:
|
||||
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())
|
||||
SOURCE_KEY = "computer_terms"
|
||||
SOURCE_LABEL = "计算机词汇大全【官方推荐】"
|
||||
SOURCE_WEIGHT = 5
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -64,24 +44,26 @@ class LexiconEntry:
|
||||
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] = {}
|
||||
source_counts: Counter[str] = Counter()
|
||||
|
||||
for spec, entries in sources:
|
||||
for word, pinyin in entries:
|
||||
source_counts[spec.key] += 1
|
||||
current = merged.get(word)
|
||||
candidate = LexiconEntry(word=word, pinyin=pinyin, source=spec.key, weight=spec.weight)
|
||||
if current is None or candidate.weight > current.weight:
|
||||
merged[word] = candidate
|
||||
elif current.weight == candidate.weight and not current.pinyin and pinyin:
|
||||
merged[word] = candidate
|
||||
for word, pinyin in entries:
|
||||
current = merged.get(word)
|
||||
candidate = LexiconEntry(
|
||||
word=word,
|
||||
pinyin=pinyin,
|
||||
source=SOURCE_KEY,
|
||||
weight=SOURCE_WEIGHT,
|
||||
)
|
||||
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)
|
||||
|
||||
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),
|
||||
"sources": [
|
||||
{
|
||||
"key": spec.key,
|
||||
"label": spec.label,
|
||||
"weight": spec.weight,
|
||||
"raw_count": source_stats.get(spec.key, 0),
|
||||
"key": SOURCE_KEY,
|
||||
"label": SOURCE_LABEL,
|
||||
"weight": SOURCE_WEIGHT,
|
||||
"raw_count": raw_count,
|
||||
}
|
||||
for spec in SOURCES
|
||||
],
|
||||
"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.",
|
||||
"Higher source weight wins on duplicate words.",
|
||||
"Merged with ai-tech-brands seed at export time for the final .bin asset.",
|
||||
],
|
||||
"files": {
|
||||
"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")
|
||||
|
||||
|
||||
def build(
|
||||
*,
|
||||
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
|
||||
|
||||
def build(*, computer_scel: Path, output_dir: Path) -> int:
|
||||
if not computer_scel.exists():
|
||||
print(f"Missing computer scel: {computer_scel}", file=sys.stderr)
|
||||
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)
|
||||
network_info = get_scel_info(network_scel)
|
||||
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]]]] = [
|
||||
(SOURCES[0], parse_scel_file(computer_scel)),
|
||||
(SOURCES[1], parse_scel_file(network_scel)),
|
||||
(SOURCES[2], load_pinyin_tsv(accumulated_tsv)),
|
||||
]
|
||||
raw_entries = parse_scel_file(computer_scel)
|
||||
merged = merge_entries(raw_entries)
|
||||
write_outputs(merged, output_dir, raw_count=len(raw_entries))
|
||||
|
||||
source_stats = {spec.key: len(entries) for spec, entries in loaded_sources}
|
||||
merged = merge_entries(loaded_sources)
|
||||
write_outputs(merged, output_dir, source_stats)
|
||||
|
||||
print(f"Raw counts: {source_stats}")
|
||||
print(f"Raw count: {len(raw_entries)}")
|
||||
print(f"Merged unique entries: {len(merged)}")
|
||||
print(f"Wrote {output_dir / 'phrases.tsv'}")
|
||||
print(f"Wrote {output_dir / 'manifest.json'}")
|
||||
@@ -166,18 +119,11 @@ def build(
|
||||
|
||||
|
||||
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("--network-scel", type=Path, default=DEFAULT_NETWORK_SCEL)
|
||||
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR)
|
||||
parser.add_argument("--skip-download", action="store_true")
|
||||
args = parser.parse_args()
|
||||
return build(
|
||||
computer_scel=args.computer_scel,
|
||||
network_scel=args.network_scel,
|
||||
output_dir=args.output_dir,
|
||||
skip_download=args.skip_download,
|
||||
)
|
||||
return build(computer_scel=args.computer_scel, output_dir=args.output_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -15,7 +15,7 @@ import Speech
|
||||
// MARK: - CLI
|
||||
|
||||
struct CLIOptions {
|
||||
var sogouTSV: URL
|
||||
var domainTSV: URL
|
||||
var aiTechTSV: URL
|
||||
var outputBin: URL
|
||||
var localeID: String
|
||||
@@ -29,14 +29,14 @@ struct CLIOptions {
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
|
||||
var sogou = repoRoot.appendingPathComponent(
|
||||
var domain = repoRoot.appendingPathComponent(
|
||||
"OSGKeyboard/Resources/CustomLanguageModel/v1/phrases.tsv"
|
||||
)
|
||||
var aiTech = repoRoot.appendingPathComponent(
|
||||
"OSGKeyboard/Resources/CustomLanguageModel/ai-tech-brands/v1/phrases.tsv"
|
||||
)
|
||||
var output = repoRoot.appendingPathComponent(
|
||||
"OSGKeyboard/Resources/CustomLanguageModel/v1/OSGKeyboardCLM.bin"
|
||||
"OSGKeyboardShared/Resources/CustomLanguageModel/v1/OSGKeyboardCLM.bin"
|
||||
)
|
||||
var localeID = "zh_CN"
|
||||
var modelID = "com.osgkeyboard.custom-lm.v1"
|
||||
@@ -46,8 +46,8 @@ struct CLIOptions {
|
||||
var iterator = CommandLine.arguments.dropFirst().makeIterator()
|
||||
while let flag = iterator.next() {
|
||||
switch flag {
|
||||
case "--sogou-tsv":
|
||||
sogou = URL(fileURLWithPath: iterator.next() ?? "")
|
||||
case "--domain-tsv", "--sogou-tsv":
|
||||
domain = URL(fileURLWithPath: iterator.next() ?? "")
|
||||
case "--ai-tech-tsv":
|
||||
aiTech = URL(fileURLWithPath: iterator.next() ?? "")
|
||||
case "--output":
|
||||
@@ -71,7 +71,7 @@ struct CLIOptions {
|
||||
}
|
||||
|
||||
return CLIOptions(
|
||||
sogouTSV: sogou,
|
||||
domainTSV: domain,
|
||||
aiTechTSV: aiTech,
|
||||
outputBin: output,
|
||||
localeID: localeID,
|
||||
@@ -86,7 +86,7 @@ struct CLIOptions {
|
||||
export_clm.swift — build SFCustomLanguageModelData .bin on macOS
|
||||
|
||||
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
|
||||
--output <path> Output .bin path
|
||||
--locale <id> Locale identifier (default: zh_CN)
|
||||
@@ -126,7 +126,7 @@ enum TSVLoader {
|
||||
}
|
||||
|
||||
// Formats:
|
||||
// sogou: word, pinyin, source, weight
|
||||
// domain: word, pinyin, source, weight
|
||||
// ai-tech: word, pinyin, source, category, weight, canonical
|
||||
let weight: Int
|
||||
if parts.count >= 6, let parsed = Int(parts[4]) {
|
||||
@@ -171,17 +171,17 @@ enum ExportCLM {
|
||||
let options = CLIOptions.parse()
|
||||
let fm = FileManager.default
|
||||
|
||||
guard fm.fileExists(atPath: options.sogouTSV.path) else {
|
||||
throw ExportError.missingInput(options.sogouTSV.path)
|
||||
guard fm.fileExists(atPath: options.domainTSV.path) else {
|
||||
throw ExportError.missingInput(options.domainTSV.path)
|
||||
}
|
||||
guard fm.fileExists(atPath: options.aiTechTSV.path) else {
|
||||
throw ExportError.missingInput(options.aiTechTSV.path)
|
||||
}
|
||||
|
||||
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")
|
||||
var merged = TSVLoader.merge([sogou, aiTech])
|
||||
var merged = TSVLoader.merge([domain, aiTech])
|
||||
|
||||
if let cap = options.maxEntries, merged.count > cap {
|
||||
merged = Array(merged.prefix(cap))
|
||||
@@ -189,7 +189,7 @@ enum ExportCLM {
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
fputs("Locale=\(options.localeID) identifier=\(options.modelID) version=\(options.modelVersion)\n", stderr)
|
||||
@@ -236,7 +236,7 @@ enum ExportCLM {
|
||||
"version": options.modelVersion,
|
||||
"phrase_count": merged.count,
|
||||
"sources": [
|
||||
"sogou_v1": sogou.count,
|
||||
"computer_terms": domain.count,
|
||||
"ai_tech_seed": aiTech.count,
|
||||
],
|
||||
"bin_file": outputURL.lastPathComponent,
|
||||
|
||||
@@ -29,7 +29,7 @@ struct PrepareOptions {
|
||||
.deletingLastPathComponent()
|
||||
|
||||
var input = repoRoot.appendingPathComponent(
|
||||
"OSGKeyboard/Resources/CustomLanguageModel/v1/OSGKeyboardCLM.bin"
|
||||
"OSGKeyboardShared/Resources/CustomLanguageModel/v1/OSGKeyboardCLM.bin"
|
||||
)
|
||||
var output = repoRoot.appendingPathComponent(
|
||||
"OSGKeyboard/Resources/CustomLanguageModel/v1/compiled"
|
||||
|
||||
@@ -379,3 +379,242 @@ embodied AI 具身智能 tech_term 80
|
||||
数字游民 shu zi you min digital nomad tech_term 75
|
||||
远程办公 yuan cheng ban gong remote work tech_term 78
|
||||
副业 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
|
||||
|
||||
|
+2
-2
@@ -21,7 +21,7 @@
|
||||
<h2>What we collect</h2>
|
||||
<ul>
|
||||
<li><strong>Voice audio</strong> — captured only while you actively record. Audio is transcribed on-device with Apple's <code>SpeechAnalyzer</code> + <code>DictationTranscriber</code>; raw audio is not uploaded by OSGKeyboard.</li>
|
||||
<li><strong>Transcribed text</strong> — after on-device ASR, the transcript (not audio) is sent for polish. On the <strong>local engine</strong>, polish uses a built-in DeepSeek endpoint configured at build time. On the <strong>cloud engine</strong>, polish and optional translation use the OpenAI-compatible API you configure (e.g. OpenAI, Qwen DashScope, Moonshot, Zhipu, or your own server).</li>
|
||||
<li><strong>Transcribed text</strong> — after on-device ASR, the transcript (not audio) is sent for polish. On the <strong>local engine</strong>, polish uses a built-in DeepSeek endpoint configured at build time. On the <strong>cloud engine</strong>, polish and optional translation use the OpenAI-compatible API you configure (e.g. OpenAI, Qwen DashScope, Moonshot, Zhipu, Xiaomi MiMo, or your own server).</li>
|
||||
<li><strong>API credentials</strong> — your cloud-engine LLM API key is stored in the iOS Keychain on your device and is read only when an LLM request is made. It is shared with the main app through a shared Keychain group, never through UserDefaults.</li>
|
||||
<li><strong>App preferences</strong> — engine mode, recognition language, polish intensity, translation target, handedness, cursor-navigation toggle, and keyboard settings are stored in App Group <code>UserDefaults</code> on your device so the main app and keyboard extension stay in sync.</li>
|
||||
<li><strong>Personal dictionary</strong> — terms and aliases you add in the Dictionary tab are stored locally on your device. They are included in LLM polish prompts so your vocabulary is preserved; dictionary data is not uploaded to a separate server.</li>
|
||||
@@ -73,7 +73,7 @@
|
||||
<h2>我们处理的数据</h2>
|
||||
<ul>
|
||||
<li><strong>语音音频</strong> — 仅在你主动录音时采集。音频在设备端通过 <code>SpeechAnalyzer</code> + <code>DictationTranscriber</code> 转写,OSGKeyboard 不会上传原始录音。</li>
|
||||
<li><strong>转写文字</strong> — 端侧 ASR 完成后,转写文字(非音频)会发送润色。<strong>本地引擎</strong>使用构建时配置的内置 DeepSeek 端点;<strong>云端引擎</strong>的润色与可选翻译使用你配置的 OpenAI 兼容 API(OpenAI / 通义 DashScope / Moonshot / 智谱 / 自建服务等)。</li>
|
||||
<li><strong>转写文字</strong> — 端侧 ASR 完成后,转写文字(非音频)会发送润色。<strong>本地引擎</strong>使用构建时配置的内置 DeepSeek 端点;<strong>云端引擎</strong>的润色与可选翻译使用你配置的 OpenAI 兼容 API(OpenAI / 通义 DashScope / Moonshot / 智谱 / 小米 MiMo / 自建服务等)。</li>
|
||||
<li><strong>API 凭证</strong> — 云端引擎的 LLM API Key 保存在设备 Keychain,仅在发起 LLM 请求时读取;通过共享 Keychain 组与主 App 共享,<strong>不会</strong>写入 <code>UserDefaults</code>。</li>
|
||||
<li><strong>应用偏好</strong> — 引擎模式、识别语言、润色档位、翻译目标、握持偏好、光标导航开关、键盘设置等保存在 App Group <code>UserDefaults</code>,仅用于主 App 与键盘扩展之间的状态同步。</li>
|
||||
<li><strong>个性词库</strong> — 你在「词库」Tab 添加的词条与别名保存在本机。润色时会写入 LLM 提示词以保留你的词汇;词库数据不会上传到独立服务器。</li>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<h2>What we collect</h2>
|
||||
<ul>
|
||||
<li><strong>Voice audio</strong> — captured only while you actively record. Audio is transcribed on-device with Apple's <code>SpeechAnalyzer</code> + <code>DictationTranscriber</code>; raw audio is not uploaded by OSGKeyboard.</li>
|
||||
<li><strong>Transcribed text</strong> — after on-device ASR, the transcript (not audio) is sent for polish. On the <strong>local engine</strong>, polish uses a built-in DeepSeek endpoint configured at build time. On the <strong>cloud engine</strong>, polish and optional translation use the OpenAI-compatible API you configure (e.g. OpenAI, Qwen DashScope, Moonshot, Zhipu, or your own server).</li>
|
||||
<li><strong>Transcribed text</strong> — after on-device ASR, the transcript (not audio) is sent for polish. On the <strong>local engine</strong>, polish uses a built-in DeepSeek endpoint configured at build time. On the <strong>cloud engine</strong>, polish and optional translation use the OpenAI-compatible API you configure (e.g. OpenAI, Qwen DashScope, Moonshot, Zhipu, Xiaomi MiMo, or your own server).</li>
|
||||
<li><strong>API credentials</strong> — your cloud-engine LLM API key is stored in the iOS Keychain on your device and is read only when an LLM request is made. It is shared with the main app through a shared Keychain group, never through UserDefaults.</li>
|
||||
<li><strong>App preferences</strong> — engine mode, recognition language, polish intensity, translation target, handedness, cursor-navigation toggle, and keyboard settings are stored in App Group <code>UserDefaults</code> on your device so the main app and keyboard extension stay in sync.</li>
|
||||
<li><strong>Personal dictionary</strong> — terms and aliases you add in the Dictionary tab are stored locally on your device. They are included in LLM polish prompts so your vocabulary is preserved; dictionary data is not uploaded to a separate server.</li>
|
||||
@@ -74,7 +74,7 @@
|
||||
<h2>我们处理的数据</h2>
|
||||
<ul>
|
||||
<li><strong>语音音频</strong> — 仅在你主动录音时采集。音频在设备端通过 <code>SpeechAnalyzer</code> + <code>DictationTranscriber</code> 转写,OSGKeyboard 不会上传原始录音。</li>
|
||||
<li><strong>转写文字</strong> — 端侧 ASR 完成后,转写文字(非音频)会发送润色。<strong>本地引擎</strong>使用构建时配置的内置 DeepSeek 端点;<strong>云端引擎</strong>的润色与可选翻译使用你配置的 OpenAI 兼容 API(OpenAI / 通义 DashScope / Moonshot / 智谱 / 自建服务等)。</li>
|
||||
<li><strong>转写文字</strong> — 端侧 ASR 完成后,转写文字(非音频)会发送润色。<strong>本地引擎</strong>使用构建时配置的内置 DeepSeek 端点;<strong>云端引擎</strong>的润色与可选翻译使用你配置的 OpenAI 兼容 API(OpenAI / 通义 DashScope / Moonshot / 智谱 / 小米 MiMo / 自建服务等)。</li>
|
||||
<li><strong>API 凭证</strong> — 云端引擎的 LLM API Key 保存在设备 Keychain,仅在发起 LLM 请求时读取;通过共享 Keychain 组与主 App 共享,<strong>不会</strong>写入 <code>UserDefaults</code>。</li>
|
||||
<li><strong>应用偏好</strong> — 引擎模式、识别语言、润色档位、翻译目标、握持偏好、光标导航开关、键盘设置等保存在 App Group <code>UserDefaults</code>,仅用于主 App 与键盘扩展之间的状态同步。</li>
|
||||
<li><strong>个性词库</strong> — 你在「词库」Tab 添加的词条与别名保存在本机。润色时会写入 LLM 提示词以保留你的词汇;词库数据不会上传到独立服务器。</li>
|
||||
|
||||
+6
-2
@@ -36,8 +36,8 @@ settings:
|
||||
GENERATE_INFOPLIST_FILE: NO
|
||||
ENABLE_MODULE_VERIFIER: YES
|
||||
CLANG_CXX_LANGUAGE_STANDARD: c++17
|
||||
MARKETING_VERSION: "0.3.6"
|
||||
CURRENT_PROJECT_VERSION: "10"
|
||||
MARKETING_VERSION: "0.4.0"
|
||||
CURRENT_PROJECT_VERSION: "11"
|
||||
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
||||
|
||||
# 项目级签名 xcconfig,适用于所有 target
|
||||
@@ -63,6 +63,7 @@ targets:
|
||||
# Legacy PNG app icons must not coexist with AppIcon.icon — actool
|
||||
# crashes when both are passed. iOS 26 uses Icon Composer only.
|
||||
- "Assets.xcassets/AppIcon.appiconset"
|
||||
- "Resources/CustomLanguageModel/**"
|
||||
entitlements:
|
||||
path: OSGKeyboard/OSGKeyboard.entitlements
|
||||
properties:
|
||||
@@ -220,6 +221,9 @@ targets:
|
||||
buildPhase: resources
|
||||
- path: OSGKeyboardShared/zh-Hans.lproj/Shared.strings
|
||||
buildPhase: resources
|
||||
resources:
|
||||
- path: OSGKeyboardShared/Resources/CustomLanguageModel/v1/OSGKeyboardCLM.bin
|
||||
- path: OSGKeyboardShared/Resources/CustomLanguageModel/v1/compiled-manifest.json
|
||||
info:
|
||||
path: OSGKeyboardShared/Info.plist
|
||||
settings:
|
||||
|
||||
Reference in New Issue
Block a user