feat(dictionary): add iCloud KVS sync, cloud ASR, and lexicon expansion

Mirror the personal dictionary through iCloud Key-Value Store with
deterministic merge rules, main-app-only sync UI, and App Group as the
keyboard runtime cache. Add cloud-engine ASR with dictionary bias and
expand the bundled custom language model lexicon.
This commit is contained in:
Rocky
2026-07-06 22:44:14 +08:00
parent 07df14b546
commit bf844caa7f
40 changed files with 5692 additions and 106 deletions
+2
View File
@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)com.osgkeyboard.ios</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.osgkeyboard.shared</string>
@@ -1,21 +1,25 @@
{
"version": "v1",
"name": "ai-tech-brands",
"generated_at": "2026-07-05T11:34:08.462301+00:00",
"generated_at": "2026-07-06T13:10:52.972532+00:00",
"locale": "zh-Hans",
"entry_count": 1259,
"entry_count": 3040,
"seed_file": "Scripts/lexicon/seeds/ai_tech_brands_seed.tsv",
"license": "MIT (curated seed; OSGKeyboard contributors)",
"categories": {
"ai_brand": 169,
"ai_model": 90,
"ai_platform": 53,
"ai_term": 197,
"dev_tool": 283,
"fintech": 7,
"tech_company": 314,
"tech_leader": 37,
"tech_term": 109
"ai_brand": 284,
"ai_model": 152,
"ai_platform": 116,
"ai_term": 270,
"athlete": 73,
"celebrity": 251,
"consumer_brand": 215,
"dev_tool": 330,
"fintech": 109,
"game_ip": 70,
"tech_company": 601,
"tech_leader": 202,
"tech_term": 367
},
"notes": [
"Curated bilingual AI brands, tech companies, terminology, and hot words.",
File diff suppressed because it is too large Load Diff
@@ -39,7 +39,7 @@ final class FlowSessionManager: ObservableObject {
private var sessionASRWarmedLocaleID: String?
private var asr: ASRService {
if let sessionASR { return sessionASR }
let service = ASRServiceFactory.make()
let service = ASRServiceFactory.make(store: store)
sessionASR = service
return service
}
@@ -342,7 +342,7 @@ final class FlowSessionManager: ObservableObject {
return
}
sessionASR?.cancel()
sessionASR = ASRServiceFactory.make()
sessionASR = ASRServiceFactory.make(store: store)
sessionASREngineMode = engineMode
sessionASRWarmedLocaleID = nil
}
+15 -3
View File
@@ -140,7 +140,8 @@ struct HomeView: View {
}
private var headerGradientColors: [Color] {
if sessionIsLive {
// API Key
if sessionIsLive, !needsCloudSetup {
return [
palette.accent.opacity(0.28),
palette.accent.opacity(0.10),
@@ -175,7 +176,14 @@ struct HomeView: View {
.fill(flowStatusColor)
.frame(width: 6, height: 6)
if flowManager.isActive,
if needsCloudSetup {
// API Key / /
Text("home.flow.notReady")
.font(TypeStyle.caption2)
.foregroundStyle(palette.warning)
.lineLimit(1)
.minimumScaleFactor(0.85)
} else if flowManager.isActive,
let expires = flowManager.sessionExpiresAt {
Text("home.flow.label")
.font(TypeStyle.caption2)
@@ -195,7 +203,10 @@ struct HomeView: View {
.minimumScaleFactor(0.85)
}
if flowManager.isActive {
if needsCloudSetup {
// API Key
EmptyView()
} else if flowManager.isActive {
Button {
flowManager.endSession()
} label: {
@@ -306,6 +317,7 @@ struct HomeView: View {
}
private var flowStatusColor: Color {
if needsCloudSetup { return palette.warning }
if flowManager.isActive { return palette.accent }
if flowManager.isStarting { return palette.accent }
if needsPermissionSetup { return palette.warning }
+11 -2
View File
@@ -38,6 +38,10 @@ struct MainAppRoot: View {
.onAppear {
flowManager.setAppForeground(scenePhase == .active)
flowManager.activateOnForeground()
PersonalDictionaryCloudSync.shared.startObservingExternalChanges()
Task {
await PersonalDictionaryCloudSync.shared.pullAndMergeIfEnabled()
}
}
.onReceive(NotificationCenter.default.publisher(for: .osgKeyboardOpenURL)) { notification in
guard let url = notification.userInfo?["url"] as? URL else { return }
@@ -50,8 +54,13 @@ struct MainAppRoot: View {
}
.onChange(of: scenePhase) { _, phase in
flowManager.handleScenePhase(phase)
guard phase == .active, config.hasCompletedOnboarding else { return }
flowManager.activateOnForeground()
guard phase == .active else { return }
if config.hasCompletedOnboarding {
flowManager.activateOnForeground()
}
Task {
await PersonalDictionaryCloudSync.shared.pullAndMergeIfEnabled()
}
}
}
@@ -0,0 +1,99 @@
// PersonalDictionaryICloudSyncRow.swift
// OSGKeyboard · Main App
//
// Settings-row toggle for mirroring the personal dictionary through
// iCloud Key-Value Store. Lives in Settings .
import SwiftUI
import OSGKeyboardShared
@MainActor
struct PersonalDictionaryICloudSyncRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
@State private var isEnabled: Bool = AppGroupStore().personalDictionaryICloudSyncEnabled
@State private var syncErrorMessage: String?
@State private var isApplyingToggle = false
private let store = AppGroupStore()
var body: some View {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Toggle(isOn: toggleBinding) {
Text("settings.personalDictionary.iCloudSync.settingsTitle")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
}
.tint(palette.accent)
.disabled(isApplyingToggle)
if let syncErrorMessage {
Text(syncErrorMessage)
.font(TypeStyle.caption2)
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, Spacing.xxs)
}
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight, alignment: .leading)
.onAppear { reloadFromStore() }
.onReceive(
NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)
) { _ in
reloadFromStore()
}
}
private var toggleBinding: Binding<Bool> {
Binding(
get: { isEnabled },
set: { newValue in
guard newValue != isEnabled else { return }
if newValue {
enableSync()
} else {
disableSync()
}
}
)
}
private func reloadFromStore() {
isEnabled = store.personalDictionaryICloudSyncEnabled
}
private func enableSync() {
isApplyingToggle = true
syncErrorMessage = nil
Task {
do {
try await PersonalDictionaryCloudSync.shared.enableSync()
reloadFromStore()
} catch let error as PersonalDictionaryCloudSyncError {
isEnabled = false
syncErrorMessage = localizedSyncError(error)
} catch {
isEnabled = false
syncErrorMessage = error.localizedDescription
}
isApplyingToggle = false
}
}
private func disableSync() {
PersonalDictionaryCloudSync.shared.disableSync()
isEnabled = false
syncErrorMessage = nil
}
private func localizedSyncError(_ error: PersonalDictionaryCloudSyncError) -> String {
switch error {
case .payloadTooLarge:
return AppL10n.string("settings.personalDictionary.iCloudSync.error.tooLarge")
case .encodeFailed, .decodeFailed:
return AppL10n.string("settings.personalDictionary.iCloudSync.error.generic")
}
}
}
+50 -54
View File
@@ -80,60 +80,48 @@ struct PersonalDictionaryView: View {
saveManualEntry(term: term, editingID: editingEntry?.id)
}
}
.task {
reloadFromStore()
await PersonalDictionaryCloudSync.shared.pullAndMergeIfEnabled()
reloadFromStore()
}
.onReceive(
NotificationCenter.default.publisher(for: .personalDictionaryDidSyncFromCloud)
) { _ in
reloadFromStore()
}
}
// MARK: - List
private var list: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: Spacing.lg) {
introBanner
ForEach(filteredSections, id: \.0) { category, items in
section(for: category, items: items)
}
}
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.md)
.tabBarScrollBottomPadding()
}
.searchable(text: $searchText, prompt: "settings.personalDictionary.search.prompt")
}
private var introBanner: some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text("settings.personalDictionary.intro.title")
.font(TypeStyle.caption)
.foregroundStyle(palette.textPrimary)
Text("settings.personalDictionary.intro.body")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Spacing.md)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
private func section(for _: PersonalDictionary.Entry.Category, items: [PersonalDictionary.Entry]) -> some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
VStack(spacing: 0) {
ForEach(Array(items.enumerated()), id: \.element.id) { index, entry in
entryRow(entry)
if index < items.count - 1 {
Divider().background(palette.divider)
List {
ForEach(filteredSections, id: \.0) { _, items in
Section {
ForEach(items) { entry in
entryRow(entry)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.listRowBackground(palette.surface)
.listRowSeparatorTint(palette.divider)
}
.onDelete { offsets in
delete(items: items, at: offsets)
}
}
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
.listStyle(.insetGrouped)
.listSectionSpacing(Spacing.lg)
.scrollContentBackground(.hidden)
.background(palette.background)
//
.contentMargins(.top, Spacing.lg, for: .scrollContent)
.searchable(
text: $searchText,
placement: .navigationBarDrawer(displayMode: .always),
prompt: "settings.personalDictionary.search.prompt"
)
.tabBarScrollBottomPadding()
}
private func entryRow(_ entry: PersonalDictionary.Entry) -> some View {
@@ -187,18 +175,10 @@ struct PersonalDictionaryView: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
delete(entry)
} label: {
Label("common.delete", systemImage: "trash")
}
}
}
private var emptyState: some View {
VStack(spacing: Spacing.sm) {
Spacer()
Image(systemName: "square.stack.3d.down.right.fill")
.font(.system(size: 36, weight: .regular))
.foregroundStyle(palette.textTertiary.opacity(0.5))
@@ -219,8 +199,11 @@ struct PersonalDictionaryView: View {
.buttonStyle(.borderedProminent)
.tint(palette.accent)
.padding(.top, Spacing.sm)
Spacer()
}
// large title
//
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
.padding(.bottom, 72)
}
// MARK: - Derived data
@@ -285,6 +268,12 @@ struct PersonalDictionaryView: View {
persist()
}
private func delete(items: [PersonalDictionary.Entry], at offsets: IndexSet) {
for index in offsets {
delete(items[index])
}
}
private func clearAll() {
dictionary = .empty
generatingAliasEntryIDs = []
@@ -294,6 +283,13 @@ struct PersonalDictionaryView: View {
private func persist() {
dictionary.version += 1
store.setPersonalDictionary(dictionary)
Task {
try? await PersonalDictionaryCloudSync.shared.pushLocalIfEnabled(dictionary)
}
}
private func reloadFromStore() {
dictionary = store.personalDictionary
}
}
@@ -48,6 +48,10 @@ struct ProviderPickerSection: View {
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
if provider.supportsPersonalDictionaryCloudASR {
personalDictionaryBadge
}
Spacer(minLength: Spacing.xs)
if selected {
@@ -80,6 +84,16 @@ struct ProviderPickerSection: View {
}
}
}
/// / GLM ASR API
private var personalDictionaryBadge: some View {
Text("settings.provider.personalDictionaryBadge")
.font(TypeStyle.caption2)
.foregroundStyle(palette.accent)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 4)
.background(palette.accentMuted, in: Capsule())
}
}
enum ProviderLogo {
+25 -9
View File
@@ -40,6 +40,7 @@ struct SettingsView: View {
ScrollView {
VStack(spacing: Spacing.md) {
languageAndPolishSection
dictionaryAndPolishSection
flowSessionSection
engineSection
// v0.2.1: hide provider/api card when the
@@ -180,15 +181,6 @@ struct SettingsView: View {
Divider().background(palette.divider)
polishIntensityPreferenceRows
if config.isTranslationRowVisible {
Divider().background(palette.divider)
TranslationPickerRow(config: config, isVisible: true)
}
Divider().background(palette.divider)
cursorDragNavigationToggleRow
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
@@ -220,6 +212,30 @@ struct SettingsView: View {
}
}
// MARK: - Dictionary & polish
private var dictionaryAndPolishSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.dictionaryAndPolish.title")
VStack(spacing: 0) {
polishIntensityPreferenceRows
Divider().background(palette.divider)
TranslationPickerRow(config: config, isVisible: config.isTranslationRowVisible)
Divider().background(palette.divider)
PersonalDictionaryICloudSyncRow()
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
}
/// v0.2.1 follow-up: dedicated section for the local engine's
/// settings (cloud-polish toggle + translation row). Renders only
/// when `engineMode == "local"` so the cloud-engine user doesn't
+11 -2
View File
@@ -98,8 +98,10 @@
"settings.engine.local.ios26" = "Always on-device, no network.";
"settings.engine.local.legacy" = "On-device ASR. Transcription only, no polish.";
"settings.engine.cloud.title" = "Cloud recognition & polish";
"settings.engine.cloud.subtitle" = "On-device ASR + polish via your API. Text is sent to the endpoint you configure.";
"settings.engine.cloud.subtitle" = "Cloud ASR (with your dictionary) + API polish. Audio is sent to your provider.";
"settings.engine.cloud.badge" = "Cloud engine";
"settings.provider.title" = "Provider";
"settings.provider.personalDictionaryBadge" = "Personal dictionary";
"settings.provider.subtitle" = "Pick the LLM that polishes your dictation.";
"provider.openai" = "OpenAI";
"provider.deepseek" = "DeepSeek";
@@ -141,6 +143,7 @@
"settings.systemPrompt.hint" = "Used by the cloud LLM when polishing your dictation. Local engine skips this step.";
"settings.about.title" = "About";
"settings.preferences.title" = "Preferences";
"settings.dictionaryAndPolish.title" = "Dictionary & polish";
"settings.handedness.title" = "Handedness";
"settings.handedness.left" = "Left hand";
"settings.handedness.right" = "Right hand";
@@ -283,6 +286,7 @@
"home.flow.active" = "Voice session active";
"home.flow.label" = "Ready";
"home.flow.inactive" = "Voice session inactive";
"home.flow.notReady" = "Not ready";
"home.flow.hint" = "Switch to any app and tap the keyboard mic to dictate.";
"home.setup.permission.mic" = "Microphone access is off — voice input won't work.";
"home.setup.permission.speech" = "Speech recognition is off — voice input won't work.";
@@ -346,7 +350,7 @@
"settings.personalDictionary.intro.title" = "About your dictionary";
"settings.personalDictionary.intro.body" = "Add words manually. Common speech-recognition mishearings are generated automatically after you save. Tap a word to edit.";
"settings.personalDictionary.empty.title" = "No words yet";
"settings.personalDictionary.empty.body" = "Add words you want protected during speech correction.";
"settings.personalDictionary.empty.body" = "Add your personal dictionary to improve recognition accuracy\nSyncs across devices via iCloud";
"settings.personalDictionary.search.prompt" = "Search words";
"settings.personalDictionary.usageCount" = "%lld uses";
"settings.personalDictionary.add.title" = "Add word";
@@ -358,6 +362,11 @@
"settings.personalDictionary.clearAll.confirmTitle" = "Clear all dictionary words?";
"settings.personalDictionary.clearAll.message" = "This removes every word the LLM was protecting. This cannot be undone.";
"settings.personalDictionary.clearAll.confirm" = "Clear all";
"settings.personalDictionary.iCloudSync.title" = "Sync via iCloud";
"settings.personalDictionary.iCloudSync.settingsTitle" = "Personal dictionary iCloud sync";
"settings.personalDictionary.iCloudSync.subtitle" = "Keep your dictionary in sync across iPhone, iPad, and Mac. Data stays in your private iCloud account and is not used for training.";
"settings.personalDictionary.iCloudSync.error.tooLarge" = "Dictionary is too large to sync via iCloud. Remove some entries and try again.";
"settings.personalDictionary.iCloudSync.error.generic" = "Could not sync your dictionary with iCloud. Try again later.";
/* v0.3.0: Polish intensity */
"settings.polishIntensity.title" = "Polish intensity";
+13 -4
View File
@@ -98,8 +98,10 @@
"settings.engine.local.ios26" = "全程在手机本地,不用联网";
"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。";
"settings.engine.cloud.title" = "云端识别与润色";
"settings.engine.cloud.subtitle" = "本地转写 + 你配置的 API 润色,文字发往第三方服务";
"settings.engine.cloud.subtitle" = "云端 ASR(含个性词库)+ API 润色,音频将发往第三方服务";
"settings.engine.cloud.badge" = "云端引擎";
"settings.provider.title" = "云端引擎";
"settings.provider.personalDictionaryBadge" = "个性词库";
"settings.provider.subtitle" = "选择 LLM 提供商。";
"provider.openai" = "OpenAI";
"provider.deepseek" = "DeepSeek";
@@ -141,6 +143,7 @@
"settings.systemPrompt.hint" = "云端润色时使用。本地识别模式不会用到此提示词。";
"settings.about.title" = "关于";
"settings.preferences.title" = "偏好设置";
"settings.dictionaryAndPolish.title" = "词库与润色";
"settings.handedness.title" = "握持偏好";
"settings.handedness.left" = "左手";
"settings.handedness.right" = "右手";
@@ -282,6 +285,7 @@
"home.flow.active" = "语音会话进行中";
"home.flow.label" = "就绪";
"home.flow.inactive" = "语音会话未启动";
"home.flow.notReady" = "未就绪";
"home.flow.hint" = "切到别的 App,点键盘麦克风就能说。";
"home.setup.permission.mic" = "麦克风还没授权,语音输入用不了。";
"home.setup.permission.speech" = "语音识别还没授权,语音输入用不了。";
@@ -345,7 +349,7 @@
"settings.personalDictionary.intro.title" = "关于词库";
"settings.personalDictionary.intro.body" = "手动添加词条;保存后会自动生成常见误识别写法。点击词条可编辑。";
"settings.personalDictionary.empty.title" = "还没有词条";
"settings.personalDictionary.empty.body" = "添加希望在语音纠错时保护的词汇。";
"settings.personalDictionary.empty.body" = "添加个性化词库,提升识别准确性\n词库将通过 iCloud 实现跨设备同步";
"settings.personalDictionary.search.prompt" = "搜索词条";
"settings.personalDictionary.usageCount" = "使用 %lld 次";
"settings.personalDictionary.add.title" = "添加词条";
@@ -357,9 +361,14 @@
"settings.personalDictionary.clearAll.confirmTitle" = "清空全部词条?";
"settings.personalDictionary.clearAll.message" = "这会移除所有受保护的词汇,且无法撤销。";
"settings.personalDictionary.clearAll.confirm" = "全部清空";
"settings.personalDictionary.iCloudSync.title" = "通过 iCloud 同步";
"settings.personalDictionary.iCloudSync.settingsTitle" = "个性词库 iCloud 同步";
"settings.personalDictionary.iCloudSync.subtitle" = "在 iPhone、iPad 和 Mac 之间同步词库。数据仅保存在你的私人 iCloud 账户中,不会用于训练。";
"settings.personalDictionary.iCloudSync.error.tooLarge" = "词库过大,无法通过 iCloud 同步。请删除部分词条后重试。";
"settings.personalDictionary.iCloudSync.error.generic" = "无法与 iCloud 同步词库,请稍后重试。";
/* v0.3.0: 润色档位 */
"settings.polishIntensity.title" = "润色档位";
/* v0.3.0: 润色强度 */
"settings.polishIntensity.title" = "润色强度";
/* Flow 会话策略 */
"settings.flow.title" = "语音会话";