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
@@ -30,6 +30,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public static let detectedAppContext = "config.detectedAppContext"
public static let detectedAppContextAt = "config.detectedAppContextAt"
public static let personalDictionary = "config.personalDictionary.v1"
/// When true, the main app mirrors the personal dictionary via iCloud KVS.
public static let personalDictionaryICloudSyncEnabled = "config.personalDictionary.iCloudSyncEnabled"
/// When true, the host app auto-returns to the source app after a cold-start handoff.
public static let flowSkipAppSwitch = "config.flowSkipAppSwitch"
/// Raw `FlowInactivityDuration` value; session expires after this idle window.
@@ -53,6 +55,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public var cursorDragNavigationEnabled: Bool
public var polishIntensity: PolishIntensity
public var personalDictionary: PersonalDictionary
/// Opt-in iCloud KVS sync for the personal dictionary (main app only).
public var personalDictionaryICloudSyncEnabled: Bool
/// Auto-return to the host app after `startflow` cold start (default on).
public var flowSkipAppSwitch: Bool
/// Idle timeout before the Flow session ends; resets on each utterance.
@@ -154,6 +158,12 @@ public struct AppGroupConfiguration: Sendable, Equatable {
}(),
polishIntensity: resolvePolishIntensity(from: defaults),
personalDictionary: decodePersonalDictionary(from: defaults),
personalDictionaryICloudSyncEnabled: {
if defaults.object(forKey: Keys.personalDictionaryICloudSyncEnabled) == nil {
return true
}
return defaults.bool(forKey: Keys.personalDictionaryICloudSyncEnabled)
}(),
flowSkipAppSwitch: {
if defaults.object(forKey: Keys.flowSkipAppSwitch) == nil {
return true
@@ -212,6 +222,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity)
defaults.set(flowSkipAppSwitch, forKey: Keys.flowSkipAppSwitch)
defaults.set(flowInactivityDuration.rawValue, forKey: Keys.flowInactivityDuration)
defaults.set(personalDictionaryICloudSyncEnabled, forKey: Keys.personalDictionaryICloudSyncEnabled)
Self.encodePersonalDictionary(personalDictionary, to: defaults)
}
@@ -0,0 +1,121 @@
// CloudASRModels.swift
// OSGKeyboard · Shared
//
// Cloud-engine ASR routing: which provider uses official hotwords /
// vocabulary APIs vs. a transcription prompt bias.
import Foundation
/// How a cloud provider applies the user's personal dictionary during ASR.
public enum CloudASRStrategy: String, Sendable, Equatable {
/// GLM-ASR `hotwords` + optional `prompt`.
case zhipuHotwords
/// Fun-ASR managed `vocabulary_id` + context text.
case alibabaVocabulary
/// OpenAI / MiMo / `prompt` on transcription APIs.
case prompt
/// Moonshot API 退 ASR
case localFallback
}
public enum CloudASRError: Error, LocalizedError, Sendable, Equatable {
case noAPIKey
case invalidURL
case http(status: Int, message: String?)
case decoding(String)
case transport(String)
case emptyTranscript
case audioTooLong
case providerUnsupported
public var errorDescription: String? {
switch self {
case .noAPIKey:
return SharedL10n.string("error.cloudASR.noAPIKey")
case .invalidURL:
return SharedL10n.string("error.cloudASR.invalidURL")
case .http(let status, let message):
if let message, !message.isEmpty {
return SharedL10n.format("error.cloudASR.httpWithMessage", status, message)
}
return SharedL10n.format("error.cloudASR.http", status)
case .decoding(let detail):
return SharedL10n.format("error.cloudASR.decoding", detail)
case .transport(let detail):
return SharedL10n.format("error.cloudASR.transport", detail)
case .emptyTranscript:
return SharedL10n.string("error.cloudASR.emptyTranscript")
case .audioTooLong:
return SharedL10n.string("error.cloudASR.audioTooLong")
case .providerUnsupported:
return SharedL10n.string("error.cloudASR.providerUnsupported")
}
}
}
public enum CloudASRModelCatalog {
/// Sync Fun-ASR Flash base64 upload, 5 min, supports context + vocabulary.
public static let alibabaFunASRFlash = "fun-asr-flash-2026-06-15"
/// Must match the ASR model used at recognition time.
public static let alibabaVocabularyTargetModel = alibabaFunASRFlash
public static let zhipuGLMASR = "glm-asr-2512"
public static let openAITranscribe = "gpt-4o-mini-transcribe"
public static let openAIWhisper = "whisper-1"
public static let mimoASR = "mimo-v2.5-asr"
public static let alibabaAPIBase = "https://dashscope.aliyuncs.com/api/v1"
public static let alibabaCustomizationPath = "/services/audio/asr/customization"
public static let alibabaMultimodalPath = "/services/aigc/multimodal-generation/generation"
public static let zhipuTranscriptionPath = "/audio/transcriptions"
public static func strategy(for providerId: String) -> CloudASRStrategy {
switch providerId {
case "zhipu":
return .zhipuHotwords
case "qwen":
return .alibabaVocabulary
case "moonshot":
return .localFallback
case "openai", "mimo", "custom":
return .prompt
default:
return .prompt
}
}
public static func defaultModel(for providerId: String) -> String {
switch providerId {
case "zhipu":
return zhipuGLMASR
case "qwen":
return alibabaFunASRFlash
case "mimo":
return mimoASR
case "openai", "custom":
return openAITranscribe
default:
return openAITranscribe
}
}
}
extension LLMProvider {
public var cloudASRStrategy: CloudASRStrategy {
CloudASRModelCatalog.strategy(for: id)
}
public var defaultCloudASRModel: String {
CloudASRModelCatalog.defaultModel(for: id)
}
/// Official hotwords / vocabulary APIs during cloud ASR (not prompt-only bias).
public var supportsPersonalDictionaryCloudASR: Bool {
switch cloudASRStrategy {
case .zhipuHotwords, .alibabaVocabulary:
return true
case .prompt, .localFallback:
return false
}
}
}
@@ -0,0 +1,114 @@
// PersonalDictionary+ASRBias.swift
// OSGKeyboard · Shared
//
// Formats the user dictionary for cloud ASR bias (hotwords, Alibaba
// vocabulary entries, or Whisper-style prompt fragments).
import Foundation
import CryptoKit
public struct AlibabaHotwordEntry: Codable, Sendable, Equatable {
public let text: String
public let weight: Int
public let lang: String?
public init(text: String, weight: Int = 4, lang: String? = nil) {
self.text = text
self.weight = weight
self.lang = lang
}
}
extension PersonalDictionary {
/// Stable fingerprint used to decide when to refresh Alibaba vocabulary.
public func vocabularySyncFingerprint() -> String {
let payload = effectiveEntries
.sorted { $0.term.localizedCaseInsensitiveCompare($1.term) == .orderedAscending }
.map { entry in
let aliases = entry.aliases
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
.joined(separator: ",")
return "\(entry.term.lowercased())|\(aliases)"
}
.joined(separator: ";")
let digest = SHA256.hash(data: Data(payload.utf8))
return digest.map { String(format: "%02x", $0) }.joined()
}
/// `hotwords` canonical terms only (aliases go to `asrPromptBias`).
public func asrHotwords(maxCount: Int = 100) -> [String] {
var seen = Set<String>()
var words: [String] = []
for entry in effectiveEntries {
let term = entry.term.trimmingCharacters(in: .whitespacesAndNewlines)
guard !term.isEmpty else { continue }
let key = term.lowercased()
guard seen.insert(key).inserted else { continue }
words.append(term)
if words.count >= maxCount { break }
}
return words
}
/// `text` + `weight` (+ optional `lang`).
public func alibabaHotwordEntries(maxCount: Int = 500, defaultWeight: Int = 4) -> [AlibabaHotwordEntry] {
var seen = Set<String>()
var entries: [AlibabaHotwordEntry] = []
for entry in effectiveEntries {
let term = entry.term.trimmingCharacters(in: .whitespacesAndNewlines)
guard !term.isEmpty, term.count <= 15 else { continue }
let key = term.lowercased()
guard seen.insert(key).inserted else { continue }
entries.append(
AlibabaHotwordEntry(
text: term,
weight: defaultWeight,
lang: Self.inferAlibabaLang(for: term)
)
)
if entries.count >= maxCount { break }
}
return entries
}
/// Whisper / OpenAI-style short prompt bias (also used by MiMo text hint).
public func asrPromptBias(maxCharacters: Int = 800) -> String {
let entries = effectiveEntries
guard !entries.isEmpty else { return "" }
var lines: [String] = []
for entry in entries {
if entry.aliases.isEmpty {
lines.append(entry.term)
} else {
let aliasHint = entry.aliases.prefix(4).joined(separator: ", ")
lines.append("\(entry.term)(常见误识别:\(aliasHint)")
}
let joined = lines.joined(separator: "")
if joined.count > maxCharacters {
if lines.count == 1 {
return String(joined.prefix(maxCharacters))
}
lines.removeLast()
break
}
}
guard !lines.isEmpty else { return "" }
let body = lines.joined(separator: "")
return "用户专有词汇,转写时请优先使用以下标准写法:\(body)"
}
/// Compact domain context for Alibaba Fun-ASR Flash `input_text`.
public func alibabaContextText(maxCharacters: Int = 1200) -> String {
let prompt = asrPromptBias(maxCharacters: maxCharacters)
guard !prompt.isEmpty else { return "" }
return prompt
}
private static func inferAlibabaLang(for term: String) -> String? {
let hasNonASCII = term.unicodeScalars.contains { !$0.isASCII }
if hasNonASCII { return "zh" }
return "en"
}
}
@@ -0,0 +1,91 @@
// PersonalDictionary+Merging.swift
// OSGKeyboard · Shared
//
// Deterministic merge rules for iCloud KVS sync. Pure logic no
// NSUbiquitousKeyValueStore dependency so unit tests stay hermetic.
import Foundation
extension PersonalDictionary {
/// Merges two dictionary snapshots for cross-device sync.
///
/// Rules:
/// - Same `id`: keep the entry with the newer `updatedAt`.
/// - Same canonical term (case-insensitive) but different `id`: union
/// aliases, take max `usageCount`, keep the newer entry's fields.
public static func merge(local: PersonalDictionary, remote: PersonalDictionary) -> PersonalDictionary {
var mergedByID: [UUID: Entry] = [:]
var canonicalOwner: [String: UUID] = [:]
func insertOrMerge(_ candidate: Entry) {
let key = candidate.term.lowercased()
if let existingID = canonicalOwner[key], var existing = mergedByID[existingID] {
if candidate.id == existingID {
mergedByID[existingID] = resolveEntryConflict(existing: existing, incoming: candidate)
return
}
existing = mergeSameTerm(existing: existing, incoming: candidate)
mergedByID[existingID] = existing
return
}
if let existing = mergedByID[candidate.id] {
mergedByID[candidate.id] = resolveEntryConflict(existing: existing, incoming: candidate)
canonicalOwner[key] = candidate.id
return
}
mergedByID[candidate.id] = candidate
canonicalOwner[key] = candidate.id
}
for entry in local.entries { insertOrMerge(entry) }
for entry in remote.entries { insertOrMerge(entry) }
let mergedEntries = mergedByID.values.sorted {
if $0.updatedAt != $1.updatedAt {
return $0.updatedAt > $1.updatedAt
}
return $0.term.localizedCaseInsensitiveCompare($1.term) == .orderedAscending
}
let lastSyncedAt = [local.lastSyncedAt, remote.lastSyncedAt]
.compactMap { $0 }
.max()
return PersonalDictionary(
entries: mergedEntries,
version: max(local.version, remote.version) + 1,
lastSyncedAt: lastSyncedAt
)
}
private static func resolveEntryConflict(existing: Entry, incoming: Entry) -> Entry {
incoming.updatedAt >= existing.updatedAt ? incoming : existing
}
private static func mergeSameTerm(existing: Entry, incoming: Entry) -> Entry {
let winner = incoming.updatedAt >= existing.updatedAt ? incoming : existing
let loser = winner.id == existing.id ? incoming : existing
var merged = winner
merged.aliases = unionAliases(
existing: winner.aliases,
incoming: loser.aliases,
excludingTerm: winner.term
)
merged.usageCount = max(winner.usageCount, loser.usageCount)
merged.updatedAt = max(winner.updatedAt, loser.updatedAt)
return merged
}
private static func unionAliases(
existing: [String],
incoming: [String],
excludingTerm: String
) -> [String] {
let termLower = excludingTerm.lowercased()
return Array(
Set((existing + incoming).filter { $0.lowercased() != termLower })
).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
}
}
@@ -21,10 +21,33 @@ import Foundation
public struct PersonalDictionary: Codable, Sendable, Equatable {
public var entries: [Entry]
public var version: Int
/// When this dictionary blob was last successfully pushed to iCloud KVS.
public var lastSyncedAt: Date?
public init(entries: [Entry] = [], version: Int = 1) {
public init(entries: [Entry] = [], version: Int = 1, lastSyncedAt: Date? = nil) {
self.entries = entries
self.version = version
self.lastSyncedAt = lastSyncedAt
}
private enum CodingKeys: String, CodingKey {
case entries
case version
case lastSyncedAt
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
entries = try container.decodeIfPresent([Entry].self, forKey: .entries) ?? []
version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1
lastSyncedAt = try container.decodeIfPresent(Date.self, forKey: .lastSyncedAt)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(entries, forKey: .entries)
try container.encode(version, forKey: .version)
try container.encodeIfPresent(lastSyncedAt, forKey: .lastSyncedAt)
}
public struct Entry: Codable, Sendable, Equatable, Identifiable {
@@ -34,6 +57,8 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
public var category: Category
public var source: Source
public var createdAt: Date
/// Last mutation time used for iCloud merge conflict resolution.
public var updatedAt: Date
public var usageCount: Int
public init(
@@ -43,6 +68,7 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
category: Category,
source: Source,
createdAt: Date = Date(),
updatedAt: Date? = nil,
usageCount: Int = 0
) {
self.id = id
@@ -51,9 +77,45 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
self.category = category
self.source = source
self.createdAt = createdAt
self.updatedAt = updatedAt ?? createdAt
self.usageCount = usageCount
}
private enum CodingKeys: String, CodingKey {
case id
case term
case aliases
case category
case source
case createdAt
case updatedAt
case usageCount
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
term = try container.decode(String.self, forKey: .term)
aliases = try container.decodeIfPresent([String].self, forKey: .aliases) ?? []
category = try container.decode(Category.self, forKey: .category)
source = try container.decode(Source.self, forKey: .source)
createdAt = try container.decodeIfPresent(Date.self, forKey: .createdAt) ?? Date()
updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? createdAt
usageCount = try container.decodeIfPresent(Int.self, forKey: .usageCount) ?? 0
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(term, forKey: .term)
try container.encode(aliases, forKey: .aliases)
try container.encode(category, forKey: .category)
try container.encode(source, forKey: .source)
try container.encode(createdAt, forKey: .createdAt)
try container.encode(updatedAt, forKey: .updatedAt)
try container.encode(usageCount, forKey: .usageCount)
}
public enum Category: String, Codable, Sendable, CaseIterable {
/// Person / place / brand / organization.
case properNoun
@@ -140,6 +202,7 @@ extension PersonalDictionary {
category: .productName,
source: .manual,
createdAt: Date(timeIntervalSince1970: 0),
updatedAt: Date(timeIntervalSince1970: 0),
usageCount: 0
),
]
@@ -182,6 +245,7 @@ extension PersonalDictionary {
if termChanged || regenerateAliases {
entry.aliases = []
}
entry.updatedAt = Date()
entries[idx] = entry
return entry
}
@@ -193,15 +257,19 @@ extension PersonalDictionary {
entry.term = trimmed
entry.category = category
entry.source = .manual
entry.updatedAt = Date()
entries[idx] = entry
return entry
}
let now = Date()
let entry = Entry(
term: trimmed,
aliases: [],
category: category,
source: .manual
source: .manual,
createdAt: now,
updatedAt: now
)
entries.append(entry)
return entry
@@ -216,6 +284,7 @@ extension PersonalDictionary {
entries[idx].aliases = Array(
Set(cleaned.filter { $0.lowercased() != termLower })
).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
entries[idx].updatedAt = Date()
}
/// Renders the entire dictionary as a prompt fragment. Entries
@@ -64,7 +64,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
}
}
/// "local" on-device ASR + built-in DeepSeek polish.
/// "cloud" on-device ASR + user's cloud LLM polish.
/// "cloud" provider cloud ASR (with personal dictionary) + user's cloud LLM polish.
@Published public var engineMode: String {
didSet {
guard !isApplyingConfiguration, engineMode != configuration.engineMode else { return }