feat(keyboard): ship AI hint carousel, home library cards, and clipboard polish
Rotate AI idle suggestions with optional remote packs, move history/dictionary onto self-sizing Home preview cards, harden clipboard capture/prompting, and simplify keyboard chrome by dropping most liquid-glass shadows.
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
// AIHintModels.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Hint cards for the AI-mode idle carousel. Remote packs use `text`; the
|
||||
// host compresses that into `displayText` before writing the ready pack.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct AIHintCard: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
/// One-line carousel label (after host keyword pass, or local catalog).
|
||||
public var displayText: String
|
||||
/// Full user message sent to the AI question LLM on tap.
|
||||
public var prompt: String
|
||||
public var category: String
|
||||
public var priority: Int
|
||||
public var source: String
|
||||
public var locale: String
|
||||
public var conditions: [String]
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
displayText: String,
|
||||
prompt: String,
|
||||
category: String,
|
||||
priority: Int = 50,
|
||||
source: String = "local",
|
||||
locale: String = "zh",
|
||||
conditions: [String] = []
|
||||
) {
|
||||
self.id = id
|
||||
self.displayText = displayText
|
||||
self.prompt = prompt
|
||||
self.category = category
|
||||
self.priority = priority
|
||||
self.source = source
|
||||
self.locale = locale
|
||||
self.conditions = conditions
|
||||
}
|
||||
|
||||
public var requiresClipboard30s: Bool {
|
||||
conditions.contains("clipboard_30s") || category == "clipboard"
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, displayText, text, prompt, category, priority, source, locale, conditions
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(String.self, forKey: .id)
|
||||
prompt = try container.decode(String.self, forKey: .prompt)
|
||||
category = try container.decodeIfPresent(String.self, forKey: .category) ?? "general"
|
||||
priority = try container.decodeIfPresent(Int.self, forKey: .priority) ?? 50
|
||||
source = try container.decodeIfPresent(String.self, forKey: .source) ?? "remote"
|
||||
locale = try container.decodeIfPresent(String.self, forKey: .locale) ?? "zh"
|
||||
conditions = try container.decodeIfPresent([String].self, forKey: .conditions) ?? []
|
||||
if let display = try container.decodeIfPresent(String.self, forKey: .displayText),
|
||||
!display.isEmpty {
|
||||
displayText = display
|
||||
} else {
|
||||
displayText = try container.decodeIfPresent(String.self, forKey: .text) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(displayText, forKey: .displayText)
|
||||
try container.encode(prompt, forKey: .prompt)
|
||||
try container.encode(category, forKey: .category)
|
||||
try container.encode(priority, forKey: .priority)
|
||||
try container.encode(source, forKey: .source)
|
||||
try container.encode(locale, forKey: .locale)
|
||||
try container.encode(conditions, forKey: .conditions)
|
||||
}
|
||||
}
|
||||
|
||||
public struct AIHintPack: Codable, Equatable, Sendable {
|
||||
public var locale: String
|
||||
public var generatedAt: String?
|
||||
public var expiresAt: String?
|
||||
public var version: Int
|
||||
public var cards: [AIHintCard]
|
||||
/// Wall-clock when the host last successfully wrote this ready pack.
|
||||
public var refreshedAt: Date?
|
||||
|
||||
public init(
|
||||
locale: String,
|
||||
generatedAt: String? = nil,
|
||||
expiresAt: String? = nil,
|
||||
version: Int = 1,
|
||||
cards: [AIHintCard] = [],
|
||||
refreshedAt: Date? = nil
|
||||
) {
|
||||
self.locale = locale
|
||||
self.generatedAt = generatedAt
|
||||
self.expiresAt = expiresAt
|
||||
self.version = version
|
||||
self.cards = cards
|
||||
self.refreshedAt = refreshedAt
|
||||
}
|
||||
}
|
||||
|
||||
public struct AIHintManifest: Codable, Equatable, Sendable {
|
||||
public var generatedAt: String?
|
||||
public var expiresAt: String?
|
||||
public var intervalHours: Int?
|
||||
public var locales: [String]?
|
||||
public var files: [String: String?]?
|
||||
|
||||
public init(
|
||||
generatedAt: String? = nil,
|
||||
expiresAt: String? = nil,
|
||||
intervalHours: Int? = nil,
|
||||
locales: [String]? = nil,
|
||||
files: [String: String?]? = nil
|
||||
) {
|
||||
self.generatedAt = generatedAt
|
||||
self.expiresAt = expiresAt
|
||||
self.intervalHours = intervalHours
|
||||
self.locales = locales
|
||||
self.files = files
|
||||
}
|
||||
}
|
||||
|
||||
public enum AIHintFeedEndpoints {
|
||||
public static let baseURL = URL(string: "https://key.osglab.com/hints")!
|
||||
public static let manifestURL = baseURL.appendingPathComponent("manifest.json")
|
||||
/// Packs the app fetches and the keyboard can resolve.
|
||||
public static let supportedLocales = ["zh", "en"]
|
||||
|
||||
public static func packURL(locale: String) -> URL {
|
||||
baseURL.appendingPathComponent("hints-\(locale).json")
|
||||
}
|
||||
}
|
||||
|
||||
public enum AIHintLocaleResolver {
|
||||
/// Only `zh-Hans` uses the Chinese pack; everything else uses English.
|
||||
public static func packLocale(
|
||||
preferredLanguages: [String] = Locale.preferredLanguages
|
||||
) -> String {
|
||||
let primary = (preferredLanguages.first ?? "").lowercased()
|
||||
if primary == "zh-hans" || primary.hasPrefix("zh-hans-") || primary.hasPrefix("zh-hans_") {
|
||||
return "zh"
|
||||
}
|
||||
return "en"
|
||||
}
|
||||
}
|
||||
|
||||
public enum AIHintAppGroupKeys {
|
||||
public static let readyPackPrefix = "hints.ready."
|
||||
public static let lastSuccessPrefix = "hints.meta.lastSuccessAt."
|
||||
public static let lastAttemptAt = "hints.meta.lastAttemptAt"
|
||||
|
||||
public static func readyPackKey(locale: String) -> String {
|
||||
readyPackPrefix + locale
|
||||
}
|
||||
|
||||
/// Freshness is tracked per locale so a zh success cannot mask an en failure.
|
||||
public static func lastSuccessKey(locale: String) -> String {
|
||||
lastSuccessPrefix + locale
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
public static let polishStyleCatalog = "config.polishStyles.v1"
|
||||
public static let activePolishStyleId = "config.activePolishStyleId"
|
||||
public static let polishStylesMigrated = "config.polishStyles.migrated"
|
||||
/// Keys used by the removed pre-v0.3 manual scenario implementation.
|
||||
/// Legacy keys from the removed manual scenario implementation.
|
||||
public static let legacyPolishScenarioId = "config.polishScenarioId"
|
||||
public static let legacySystemPrompt = "config.systemPrompt"
|
||||
/// When true, the main app mirrors the personal dictionary via iCloud KVS.
|
||||
@@ -230,7 +230,9 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
return load(fromAvailable: store)
|
||||
}
|
||||
|
||||
/// Loads configuration from a known-available UserDefaults suite.
|
||||
/// Loads and idempotently migrates a known-available suite; this is not a
|
||||
/// pure read. Missing defaults distinguish upgrades (legacy cloud engine)
|
||||
/// from fresh installs (local engine) before being persisted.
|
||||
public static func load(fromAvailable defaults: UserDefaults) -> AppGroupConfiguration {
|
||||
let storedProviderId = defaults.string(forKey: Keys.providerId)
|
||||
var config = AppGroupConfiguration(
|
||||
@@ -336,6 +338,15 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
|
||||
// Legacy qwen cloud ASR → bailian realtime (HTTP Flash path removed).
|
||||
if config.asrProviderId == "qwen" {
|
||||
do {
|
||||
try Keychain.copyQwenASRKeyToBailian(
|
||||
useICloudSync: config.settingsICloudSyncEnabled
|
||||
)
|
||||
} catch {
|
||||
OSGLog.config.warning(
|
||||
"qwen ASR credential migration deferred: \(String(describing: error), privacy: .public)"
|
||||
)
|
||||
}
|
||||
let bailian = LLMProvider.provider(id: "bailian")
|
||||
config.asrProviderId = "bailian"
|
||||
config.asrBaseURL = bailian.defaultBaseURL
|
||||
@@ -508,7 +519,9 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the API key from the Keychain, falling back to a one-time migration from UserDefaults.
|
||||
/// Resolves the provider-scoped Keychain item, then the legacy `current`
|
||||
/// account, then plaintext defaults. Legacy sources are removed after the
|
||||
/// selected local or synchronizable target is read back exactly.
|
||||
static func resolveAPIKey(
|
||||
defaults: UserDefaults?,
|
||||
providerId: String,
|
||||
@@ -518,20 +531,40 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
return stored
|
||||
}
|
||||
if let legacyKeychain = Keychain.legacyAPIKey(), !legacyKeychain.isEmpty {
|
||||
try? Keychain.setAPIKey(legacyKeychain, for: providerId, useICloudSync: preferICloudSync)
|
||||
try? Keychain.deleteLegacyAPIKey()
|
||||
do {
|
||||
try Keychain.migrateLegacyAPIKey(
|
||||
to: providerId,
|
||||
useICloudSync: preferICloudSync
|
||||
)
|
||||
} catch {
|
||||
OSGLog.config.warning(
|
||||
"legacy Keychain credential migration deferred: \(String(describing: error), privacy: .public)"
|
||||
)
|
||||
}
|
||||
return legacyKeychain
|
||||
}
|
||||
if let defaults,
|
||||
let legacy = defaults.string(forKey: Keys.apiKeyLegacy),
|
||||
!legacy.isEmpty {
|
||||
try? Keychain.setAPIKey(legacy, for: providerId, useICloudSync: preferICloudSync)
|
||||
defaults.removeObject(forKey: Keys.apiKeyLegacy)
|
||||
do {
|
||||
try Keychain.copyAPIKeyToSelectedStorage(
|
||||
legacy,
|
||||
providerId: providerId,
|
||||
useICloudSync: preferICloudSync
|
||||
)
|
||||
defaults.removeObject(forKey: Keys.apiKeyLegacy)
|
||||
} catch {
|
||||
OSGLog.config.warning(
|
||||
"legacy defaults credential migration deferred: \(String(describing: error), privacy: .public)"
|
||||
)
|
||||
}
|
||||
return legacy
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/// Resolves the ASR-scoped account first, then falls back to the matching
|
||||
/// polish-provider account used before ASR credentials were split.
|
||||
static func resolveASRAPIKey(
|
||||
defaults: UserDefaults?,
|
||||
providerId: String,
|
||||
@@ -540,6 +573,23 @@ public struct AppGroupConfiguration: Sendable, Equatable {
|
||||
if let stored = Keychain.asrApiKey(for: providerId, preferICloudSync: preferICloudSync), !stored.isEmpty {
|
||||
return stored
|
||||
}
|
||||
if providerId == "bailian" {
|
||||
try? Keychain.copyQwenASRKeyToBailian(useICloudSync: preferICloudSync)
|
||||
if let migrated = Keychain.asrApiKey(
|
||||
for: providerId,
|
||||
preferICloudSync: preferICloudSync
|
||||
), !migrated.isEmpty {
|
||||
return migrated
|
||||
}
|
||||
// Keep a compatibility read while older signed installs may still
|
||||
// hold the DashScope credential under qwen accounts.
|
||||
if let legacyQwen = Keychain.asrApiKey(
|
||||
for: "qwen",
|
||||
preferICloudSync: preferICloudSync
|
||||
), !legacyQwen.isEmpty {
|
||||
return legacyQwen
|
||||
}
|
||||
}
|
||||
// Pre-split installs: one shared key under `provider.<id>`.
|
||||
return resolveAPIKey(defaults: defaults, providerId: providerId, preferICloudSync: preferICloudSync)
|
||||
}
|
||||
|
||||
@@ -60,7 +60,8 @@ public struct EditableInputReference: Codable, Equatable, Sendable {
|
||||
now >= expiresAt
|
||||
}
|
||||
|
||||
/// Rebuilt extensions must prove the entire insertion is still at the caret.
|
||||
/// Rebuilt extensions must match the complete inserted string at the caret
|
||||
/// and, when captured, the same field fingerprint; a suffix sample is insufficient.
|
||||
public func isFullyVerified(
|
||||
contextBeforeInput: String?,
|
||||
fieldFingerprint: String?
|
||||
@@ -75,6 +76,9 @@ public struct EditableInputReference: Codable, Equatable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// App Group cache shared across extension instances. Loads evict references
|
||||
/// after their TTL; callers must never save secure-field text because this is
|
||||
/// cross-process persistence, not protected credential storage.
|
||||
public enum EditableInputReferenceStore {
|
||||
private static let key = "editLastInput.reference.v1"
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// FlowAck.swift
|
||||
// OSGKeyboard · Shared
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Keyboard delivery acknowledgement. It carries the echoed result identity,
|
||||
/// generation, and revision; matching identity/revision releases that terminal result.
|
||||
public struct FlowAck: Codable, Equatable, Sendable {
|
||||
public enum DeliveryOutcome: String, Codable, Sendable {
|
||||
case replaced
|
||||
case appended
|
||||
case rejected
|
||||
}
|
||||
|
||||
public let protocolVersion: Int
|
||||
public let sessionId: UUID
|
||||
public let utteranceId: UUID
|
||||
public let commandSeq: Int64
|
||||
public let hostGeneration: String?
|
||||
public let revision: Int64?
|
||||
public let deliveryOutcome: DeliveryOutcome?
|
||||
public let consumedAt: TimeInterval
|
||||
|
||||
public init(
|
||||
protocolVersion: Int = 1,
|
||||
sessionId: UUID,
|
||||
utteranceId: UUID,
|
||||
commandSeq: Int64,
|
||||
hostGeneration: String? = nil,
|
||||
revision: Int64? = nil,
|
||||
deliveryOutcome: DeliveryOutcome? = nil,
|
||||
consumedAt: TimeInterval = Date().timeIntervalSince1970
|
||||
) {
|
||||
self.protocolVersion = protocolVersion
|
||||
self.sessionId = sessionId
|
||||
self.utteranceId = utteranceId
|
||||
self.commandSeq = commandSeq
|
||||
self.hostGeneration = hostGeneration
|
||||
self.revision = revision
|
||||
self.deliveryOutcome = deliveryOutcome
|
||||
self.consumedAt = consumedAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// FlowCommand.swift
|
||||
// OSGKeyboard · Shared
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct FlowCommand: Codable, Equatable, Sendable {
|
||||
public enum Action: String, Codable, Sendable {
|
||||
case startRecording
|
||||
case stopRecording
|
||||
case abort
|
||||
/// Light warm-up: ASR locale/assets only — no mic capture.
|
||||
case prewarm
|
||||
/// User has touched the mic; prime capture before tap/hold resolves.
|
||||
case primeAudio
|
||||
/// Touch ended without an utterance adopting the primed capture.
|
||||
case cancelPrimeAudio
|
||||
/// Remove one temporary AI conversation from host memory.
|
||||
case endAIConversation
|
||||
/// AI mode: submit a prefilled question and skip ASR.
|
||||
case submitAIQuestion
|
||||
}
|
||||
|
||||
/// Wire version that includes submitAIQuestion + aiQuestionText.
|
||||
public static let currentProtocolVersion = 5
|
||||
|
||||
public let protocolVersion: Int
|
||||
public let sessionId: UUID
|
||||
public let utteranceId: UUID
|
||||
public let commandSeq: Int64
|
||||
public let action: Action
|
||||
public let localeId: String
|
||||
public let createdAt: TimeInterval
|
||||
public let fieldContext: FlowFieldContext?
|
||||
/// Dictation (default) vs explicit edit mode. Absent on legacy v1 → dictation.
|
||||
public let utteranceMode: FlowUtteranceMode?
|
||||
/// Verified source for explicit last-input editing.
|
||||
public let editSourceText: String?
|
||||
public let sourceHistoryEntryID: UUID?
|
||||
public let sourceHistoryEntryRevision: Int64?
|
||||
/// Host-memory conversation used only by `.aiQuestion`.
|
||||
public let aiConversationID: UUID?
|
||||
/// Prefilled question used only by `.submitAIQuestion`.
|
||||
public let aiQuestionText: String?
|
||||
/// Absolute wall-clock deadlines survive extension reconstruction.
|
||||
public let startDeadlineAt: TimeInterval?
|
||||
public let processingDeadlineAt: TimeInterval?
|
||||
|
||||
public init(
|
||||
protocolVersion: Int = FlowCommand.currentProtocolVersion,
|
||||
sessionId: UUID,
|
||||
utteranceId: UUID,
|
||||
commandSeq: Int64,
|
||||
action: Action,
|
||||
localeId: String,
|
||||
createdAt: TimeInterval = Date().timeIntervalSince1970,
|
||||
fieldContext: FlowFieldContext? = nil,
|
||||
utteranceMode: FlowUtteranceMode? = nil,
|
||||
editSourceText: String? = nil,
|
||||
sourceHistoryEntryID: UUID? = nil,
|
||||
sourceHistoryEntryRevision: Int64? = nil,
|
||||
aiConversationID: UUID? = nil,
|
||||
aiQuestionText: String? = nil,
|
||||
startDeadlineAt: TimeInterval? = nil,
|
||||
processingDeadlineAt: TimeInterval? = nil
|
||||
) {
|
||||
self.protocolVersion = protocolVersion
|
||||
self.sessionId = sessionId
|
||||
self.utteranceId = utteranceId
|
||||
self.commandSeq = commandSeq
|
||||
self.action = action
|
||||
self.localeId = localeId
|
||||
self.createdAt = createdAt
|
||||
self.fieldContext = fieldContext
|
||||
self.utteranceMode = utteranceMode
|
||||
self.editSourceText = editSourceText
|
||||
self.sourceHistoryEntryID = sourceHistoryEntryID
|
||||
self.sourceHistoryEntryRevision = sourceHistoryEntryRevision
|
||||
self.aiConversationID = aiConversationID
|
||||
self.aiQuestionText = aiQuestionText
|
||||
self.startDeadlineAt = startDeadlineAt
|
||||
self.processingDeadlineAt = processingDeadlineAt
|
||||
}
|
||||
|
||||
public var resolvedUtteranceMode: FlowUtteranceMode {
|
||||
utteranceMode ?? .dictation
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// FlowFieldContext.swift
|
||||
// OSGKeyboard · Shared
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct FlowFieldContext: Codable, Equatable, Sendable {
|
||||
public let precedingText: String?
|
||||
public let followingText: String?
|
||||
public let keyboardType: String?
|
||||
public let returnKeyType: String?
|
||||
public let isSecureEntry: Bool
|
||||
/// Distinguishes a known-empty field from unavailable document context.
|
||||
public let isEmptyField: Bool
|
||||
public let isContextAvailable: Bool
|
||||
|
||||
public init(
|
||||
precedingText: String? = nil,
|
||||
followingText: String? = nil,
|
||||
keyboardType: String? = nil,
|
||||
returnKeyType: String? = nil,
|
||||
isSecureEntry: Bool = false,
|
||||
isEmptyField: Bool = false,
|
||||
isContextAvailable: Bool = false
|
||||
) {
|
||||
self.precedingText = isSecureEntry ? nil : precedingText
|
||||
self.followingText = isSecureEntry ? nil : followingText
|
||||
self.keyboardType = keyboardType
|
||||
self.returnKeyType = returnKeyType
|
||||
self.isSecureEntry = isSecureEntry
|
||||
self.isEmptyField = isSecureEntry ? false : isEmptyField
|
||||
self.isContextAvailable = isSecureEntry ? false : isContextAvailable
|
||||
}
|
||||
|
||||
public var deliveryFingerprint: String? {
|
||||
guard !isSecureEntry else { return nil }
|
||||
return [
|
||||
keyboardType ?? "",
|
||||
returnKeyType ?? "",
|
||||
precedingText.map { String($0.suffix(80)) } ?? "",
|
||||
followingText.map { String($0.prefix(40)) } ?? "",
|
||||
].joined(separator: "|")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// FlowReadySnapshot.swift
|
||||
// OSGKeyboard · Shared
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct FlowReadySnapshot: Codable, Equatable, Sendable {
|
||||
public enum Reason: String, Codable, Sendable {
|
||||
case ready
|
||||
case noSession
|
||||
case starting
|
||||
case audioEngineNotLive
|
||||
case waitingForAudioProof
|
||||
case recording
|
||||
case processing
|
||||
case awaitingDelivery
|
||||
case permissionMissing
|
||||
case appGroupUnavailable
|
||||
case hostLost
|
||||
case error
|
||||
}
|
||||
|
||||
public let protocolVersion: Int
|
||||
public let sessionId: UUID?
|
||||
public let ready: Bool
|
||||
public let reason: Reason
|
||||
public let heartbeatAt: TimeInterval
|
||||
public let readyAt: TimeInterval?
|
||||
public let audioProofAt: TimeInterval?
|
||||
public let engineMode: String
|
||||
public let localeId: String
|
||||
public let busyUtteranceId: UUID?
|
||||
public let sessionExpiresAt: TimeInterval?
|
||||
/// Host process generation that wrote this snapshot. A snapshot whose
|
||||
/// generation no longer matches `FlowSessionKeys.hostGeneration` was
|
||||
/// written by a dead process and is void immediately — no need to wait
|
||||
/// out the heartbeat-zombie window. Optional for wire compatibility with
|
||||
/// snapshots written before this field existed.
|
||||
public let hostGeneration: String?
|
||||
|
||||
public init(
|
||||
protocolVersion: Int = 1,
|
||||
sessionId: UUID?,
|
||||
ready: Bool,
|
||||
reason: Reason,
|
||||
heartbeatAt: TimeInterval = Date().timeIntervalSince1970,
|
||||
readyAt: TimeInterval? = nil,
|
||||
audioProofAt: TimeInterval? = nil,
|
||||
engineMode: String,
|
||||
localeId: String,
|
||||
busyUtteranceId: UUID? = nil,
|
||||
sessionExpiresAt: TimeInterval? = nil,
|
||||
hostGeneration: String? = nil
|
||||
) {
|
||||
self.protocolVersion = protocolVersion
|
||||
self.sessionId = sessionId
|
||||
self.ready = ready
|
||||
self.reason = reason
|
||||
self.heartbeatAt = heartbeatAt
|
||||
self.readyAt = readyAt
|
||||
self.audioProofAt = audioProofAt
|
||||
self.engineMode = engineMode
|
||||
self.localeId = localeId
|
||||
self.busyUtteranceId = busyUtteranceId
|
||||
self.sessionExpiresAt = sessionExpiresAt
|
||||
self.hostGeneration = hostGeneration
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// FlowResult.swift
|
||||
// OSGKeyboard · Shared
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct FlowResult: Codable, Equatable, Sendable {
|
||||
public enum Status: String, Codable, Sendable {
|
||||
case partial
|
||||
case rawReady
|
||||
/// AI-mode LLM answer draft (not ASR). Non-terminal.
|
||||
case streaming
|
||||
case final
|
||||
case error
|
||||
case aborted
|
||||
case timeout
|
||||
}
|
||||
|
||||
public let protocolVersion: Int
|
||||
public let sessionId: UUID
|
||||
public let utteranceId: UUID
|
||||
public let commandSeq: Int64
|
||||
public let status: Status
|
||||
public let text: String?
|
||||
public let warning: String?
|
||||
public let errorKind: FlowSessionKeys.TranscriptionErrorKind?
|
||||
/// Raw ASR survives polish/network failure and host process churn.
|
||||
public let rawText: String?
|
||||
public let hostGeneration: String?
|
||||
/// Monotonically increases within one session/utterance; readers may discard
|
||||
/// an equal or lower non-nil revision as a stale or duplicate delivery.
|
||||
public let revision: Int64?
|
||||
public let fieldFingerprint: String?
|
||||
public let createdAt: TimeInterval
|
||||
/// Echo of the command mode so the extension can skip raw fallback.
|
||||
public let utteranceMode: FlowUtteranceMode?
|
||||
/// History row created by normal dictation, or edited by edit mode.
|
||||
public let historyEntryID: UUID?
|
||||
public let historyEntryRevision: Int64?
|
||||
/// Echoed for AI result validation; absent for dictation and edit.
|
||||
public let aiConversationID: UUID?
|
||||
|
||||
public init(
|
||||
protocolVersion: Int = FlowCommand.currentProtocolVersion,
|
||||
sessionId: UUID,
|
||||
utteranceId: UUID,
|
||||
commandSeq: Int64,
|
||||
status: Status,
|
||||
text: String? = nil,
|
||||
warning: String? = nil,
|
||||
errorKind: FlowSessionKeys.TranscriptionErrorKind? = nil,
|
||||
rawText: String? = nil,
|
||||
hostGeneration: String? = nil,
|
||||
revision: Int64? = nil,
|
||||
fieldFingerprint: String? = nil,
|
||||
createdAt: TimeInterval = Date().timeIntervalSince1970,
|
||||
utteranceMode: FlowUtteranceMode? = nil,
|
||||
historyEntryID: UUID? = nil,
|
||||
historyEntryRevision: Int64? = nil,
|
||||
aiConversationID: UUID? = nil
|
||||
) {
|
||||
self.protocolVersion = protocolVersion
|
||||
self.sessionId = sessionId
|
||||
self.utteranceId = utteranceId
|
||||
self.commandSeq = commandSeq
|
||||
self.status = status
|
||||
self.text = text
|
||||
self.warning = warning
|
||||
self.errorKind = errorKind
|
||||
self.rawText = rawText
|
||||
self.hostGeneration = hostGeneration
|
||||
self.revision = revision
|
||||
self.fieldFingerprint = fieldFingerprint
|
||||
self.createdAt = createdAt
|
||||
self.utteranceMode = utteranceMode
|
||||
self.historyEntryID = historyEntryID
|
||||
self.historyEntryRevision = historyEntryRevision
|
||||
self.aiConversationID = aiConversationID
|
||||
}
|
||||
|
||||
public var resolvedUtteranceMode: FlowUtteranceMode {
|
||||
utteranceMode ?? .dictation
|
||||
}
|
||||
|
||||
/// Instruction deliveries must never insert raw ASR into the field.
|
||||
public var allowsRawFallback: Bool {
|
||||
resolvedUtteranceMode == .dictation
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// FlowStartTransaction.swift
|
||||
// OSGKeyboard · Shared
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct FlowStartTransaction: Codable, Equatable, Sendable {
|
||||
public enum Phase: String, Codable, Sendable {
|
||||
case issued
|
||||
case starting
|
||||
case recording
|
||||
case terminal
|
||||
}
|
||||
|
||||
public let sessionID: UUID
|
||||
public let utteranceID: UUID
|
||||
public let deadlineAt: TimeInterval
|
||||
public let phase: Phase
|
||||
public let updatedAt: TimeInterval
|
||||
|
||||
public init(
|
||||
sessionID: UUID,
|
||||
utteranceID: UUID,
|
||||
deadlineAt: TimeInterval,
|
||||
phase: Phase,
|
||||
updatedAt: TimeInterval = Date().timeIntervalSince1970
|
||||
) {
|
||||
self.sessionID = sessionID
|
||||
self.utteranceID = utteranceID
|
||||
self.deadlineAt = deadlineAt
|
||||
self.phase = phase
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// FlowTranscriptionError.swift
|
||||
// OSGKeyboard · Shared
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
||||
public let sourceHistoryEntryID: UUID?
|
||||
public let sourceHistoryEntryRevision: Int64?
|
||||
public let aiConversationID: UUID?
|
||||
/// When set with `.aiQuestion`, host skips ASR and answers this text.
|
||||
public let aiQuestionText: String?
|
||||
|
||||
public static let dictation = FlowUtteranceRequest(mode: .dictation)
|
||||
|
||||
@@ -19,13 +21,15 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
||||
editSourceText: String? = nil,
|
||||
sourceHistoryEntryID: UUID? = nil,
|
||||
sourceHistoryEntryRevision: Int64? = nil,
|
||||
aiConversationID: UUID? = nil
|
||||
aiConversationID: UUID? = nil,
|
||||
aiQuestionText: String? = nil
|
||||
) {
|
||||
self.mode = mode
|
||||
self.editSourceText = editSourceText
|
||||
self.sourceHistoryEntryID = sourceHistoryEntryID
|
||||
self.sourceHistoryEntryRevision = sourceHistoryEntryRevision
|
||||
self.aiConversationID = aiConversationID
|
||||
self.aiQuestionText = aiQuestionText
|
||||
}
|
||||
|
||||
public static func editLastInput(
|
||||
@@ -42,10 +46,14 @@ public struct FlowUtteranceRequest: Equatable, Sendable {
|
||||
public var isEdit: Bool { mode == .editLastInput }
|
||||
public var isAIQuestion: Bool { mode == .aiQuestion }
|
||||
|
||||
public static func aiQuestion(conversationID: UUID) -> FlowUtteranceRequest {
|
||||
public static func aiQuestion(
|
||||
conversationID: UUID,
|
||||
prefilledQuestion: String? = nil
|
||||
) -> FlowUtteranceRequest {
|
||||
FlowUtteranceRequest(
|
||||
mode: .aiQuestion,
|
||||
aiConversationID: conversationID
|
||||
aiConversationID: conversationID,
|
||||
aiQuestionText: prefilledQuestion
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,10 +90,9 @@ public struct LLMRequest: Codable, Sendable {
|
||||
var cjkCount = 0
|
||||
var nonCJKCount = 0
|
||||
for scalar in text.unicodeScalars {
|
||||
switch scalar.value {
|
||||
case 0x4E00...0x9FFF, 0x3400...0x4DBF, 0xF900...0xFAFF:
|
||||
if HanScript.isIdeograph(scalar) {
|
||||
cjkCount += 1
|
||||
default:
|
||||
} else {
|
||||
nonCJKCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// LocalASRCapabilities.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Declares what each on-device ASR backend can accept for vocabulary bias.
|
||||
// Callers must consult capabilities before building a `LocalASRBiasPayload`.
|
||||
// Declares vocabulary-bias capabilities for the current macOS Qwen3 MLX
|
||||
// runtime and Apple Speech fallback. Sherpa entries remain only to interpret
|
||||
// legacy backend identifiers and install state.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -62,7 +63,7 @@ public struct LocalASRCapabilities: Sendable, Equatable {
|
||||
hotwordReloadCost: .none
|
||||
)
|
||||
|
||||
/// Sherpa Qwen3 — hard hotwords via `--qwen3-asr-hotwords`.
|
||||
/// Legacy Sherpa Qwen3 capability retained for persisted backend compatibility.
|
||||
public static let sherpaQwen3 = LocalASRCapabilities(
|
||||
hotwordMode: .recognizerScoped,
|
||||
maxHotwordCount: 100,
|
||||
@@ -71,7 +72,7 @@ public struct LocalASRCapabilities: Sendable, Equatable {
|
||||
hotwordReloadCost: .recognizerReload
|
||||
)
|
||||
|
||||
/// Sherpa SenseVoice — fast Chinese baseline without hotwords.
|
||||
/// Legacy Sherpa SenseVoice capability retained for persisted backend compatibility.
|
||||
public static let sherpaSenseVoice = LocalASRCapabilities(
|
||||
hotwordMode: .none,
|
||||
maxHotwordCount: 0,
|
||||
@@ -80,7 +81,7 @@ public struct LocalASRCapabilities: Sendable, Equatable {
|
||||
hotwordReloadCost: .none
|
||||
)
|
||||
|
||||
/// FunASR Paraformer (Sherpa offline) — no project hotword API.
|
||||
/// Legacy Sherpa Paraformer capability retained for persisted backend compatibility.
|
||||
public static let sherpaParaformer = LocalASRCapabilities(
|
||||
hotwordMode: .none,
|
||||
maxHotwordCount: 0,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// LocalASRModelCatalog.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Bundled catalog of downloadable / manual local ASR models and Sherpa runtimes.
|
||||
// Bundled macOS local-ASR model catalog. Qwen3 MLX is the active default and
|
||||
// Apple Speech is the fallback; Sherpa backend/runtime identifiers remain for
|
||||
// decoding legacy catalog and persisted install state, not current recognition.
|
||||
|
||||
import Foundation
|
||||
|
||||
|
||||
@@ -260,7 +260,7 @@ extension PersonalDictionary {
|
||||
guard !term.isEmpty else { return false }
|
||||
var hasLatinLetter = false
|
||||
for scalar in term.unicodeScalars {
|
||||
if isCJKIdeograph(scalar) { return false }
|
||||
if HanScript.isIdeograph(scalar) { return false }
|
||||
if scalar.isASCII, CharacterSet.letters.contains(scalar) {
|
||||
hasLatinLetter = true
|
||||
}
|
||||
@@ -268,15 +268,6 @@ extension PersonalDictionary {
|
||||
return hasLatinLetter
|
||||
}
|
||||
|
||||
private static func isCJKIdeograph(_ scalar: Unicode.Scalar) -> Bool {
|
||||
switch scalar.value {
|
||||
case 0x3400...0x4DBF, 0x4E00...0x9FFF, 0xF900...0xFAFF:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Case-insensitive lookup by canonical term.
|
||||
public func entry(matchingTerm term: String) -> Entry? {
|
||||
let key = term.lowercased()
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// UI-owned ObservableObject; construct and mutate it on the main thread.
|
||||
/// `@unchecked Sendable` does not make `@Published` thread-safe. Credential
|
||||
/// observers write only Keychain, while non-secret configuration uses App Group defaults.
|
||||
public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
public static let shared = ProviderConfig()
|
||||
|
||||
@@ -32,6 +35,8 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
/// Its observer updates only the provider-scoped Keychain item; the value
|
||||
/// must never enter `configuration` or App Group UserDefaults.
|
||||
@Published public var apiKey: String {
|
||||
didSet {
|
||||
guard oldValue != apiKey, !isSyncingProviderAPIKey else { return }
|
||||
@@ -42,6 +47,9 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
useICloudSync: configuration.settingsICloudSyncEnabled
|
||||
)
|
||||
} catch {
|
||||
isSyncingProviderAPIKey = true
|
||||
apiKey = oldValue
|
||||
isSyncingProviderAPIKey = false
|
||||
OSGLog.config.warning("Keychain write failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
@@ -80,6 +88,9 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
useICloudSync: configuration.settingsICloudSyncEnabled
|
||||
)
|
||||
} catch {
|
||||
isSyncingASRProviderAPIKey = true
|
||||
asrApiKey = oldValue
|
||||
isSyncingASRProviderAPIKey = false
|
||||
OSGLog.config.warning("ASR Keychain write failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
@@ -167,7 +178,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
persistConfiguration()
|
||||
}
|
||||
}
|
||||
/// v0.2.1: whether to translate the transcript into
|
||||
/// 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
|
||||
@@ -175,7 +186,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
public var translationEnabled: Bool {
|
||||
configuration.translationEnabled
|
||||
}
|
||||
/// v0.2.1: BCP-47-ish target language id (e.g. `en`, `ja`, `ko`) the
|
||||
/// BCP-47-ish target language id (e.g. `en`, `ja`, `ko`) the
|
||||
/// translate-and-polish prompt should produce. Default `"off"` —
|
||||
/// translation is opt-in. Persisted in the App Group so the keyboard
|
||||
/// extension can honour it (and so the chip on the keyboard reflects
|
||||
@@ -349,6 +360,8 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private var configuration: AppGroupConfiguration
|
||||
/// Suppresses `@Published` observer persistence while a complete snapshot
|
||||
/// or preset is applied, preventing reentrant writes of partial state.
|
||||
private var isApplyingConfiguration = false
|
||||
private var isSyncingProviderAPIKey = false
|
||||
private var isSyncingASRProviderAPIKey = false
|
||||
|
||||
@@ -31,8 +31,6 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
public var aiResponseLength: SyncedField<AIResponseLength>
|
||||
public var activePolishStyleId: SyncedField<String>
|
||||
public var llmThinkingEnabled: SyncedField<Bool>
|
||||
public var clipboardHistoryEnabled: SyncedField<Bool>
|
||||
public var clipboardCandidateBarEnabled: SyncedField<Bool>
|
||||
public var flowSkipAppSwitch: SyncedField<Bool>
|
||||
public var flowInactivityDuration: SyncedField<FlowInactivityDuration>
|
||||
|
||||
@@ -90,16 +88,10 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
)
|
||||
self.activePolishStyleId = activePolishStyleId
|
||||
self.llmThinkingEnabled = llmThinkingEnabled
|
||||
self.clipboardHistoryEnabled = clipboardHistoryEnabled ?? SyncedField(
|
||||
value: false,
|
||||
updatedAt: llmThinkingEnabled.updatedAt,
|
||||
deviceID: llmThinkingEnabled.deviceID
|
||||
)
|
||||
self.clipboardCandidateBarEnabled = clipboardCandidateBarEnabled ?? SyncedField(
|
||||
value: false,
|
||||
updatedAt: llmThinkingEnabled.updatedAt,
|
||||
deviceID: llmThinkingEnabled.deviceID
|
||||
)
|
||||
// Kept as optional parameters so old call sites and payload fixtures
|
||||
// remain source-compatible. Clipboard consent is device-local.
|
||||
_ = clipboardHistoryEnabled
|
||||
_ = clipboardCandidateBarEnabled
|
||||
self.flowSkipAppSwitch = flowSkipAppSwitch
|
||||
self.flowInactivityDuration = flowInactivityDuration
|
||||
}
|
||||
@@ -198,21 +190,13 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
updatedAt: keyboardHapticIntensity.updatedAt,
|
||||
deviceID: keyboardHapticIntensity.deviceID
|
||||
)
|
||||
clipboardHistoryEnabled = try container.decodeIfPresent(
|
||||
_ = try container.decodeIfPresent(
|
||||
SyncedField<Bool>.self,
|
||||
forKey: .clipboardHistoryEnabled
|
||||
) ?? SyncedField(
|
||||
value: false,
|
||||
updatedAt: keyboardHapticIntensity.updatedAt,
|
||||
deviceID: keyboardHapticIntensity.deviceID
|
||||
)
|
||||
clipboardCandidateBarEnabled = try container.decodeIfPresent(
|
||||
_ = try container.decodeIfPresent(
|
||||
SyncedField<Bool>.self,
|
||||
forKey: .clipboardCandidateBarEnabled
|
||||
) ?? SyncedField(
|
||||
value: false,
|
||||
updatedAt: keyboardHapticIntensity.updatedAt,
|
||||
deviceID: keyboardHapticIntensity.deviceID
|
||||
)
|
||||
flowSkipAppSwitch = try container.decode(SyncedField<Bool>.self, forKey: .flowSkipAppSwitch)
|
||||
flowInactivityDuration = try container.decode(
|
||||
@@ -269,12 +253,36 @@ public struct SyncedAppSettingsV2: Codable, Equatable, Sendable {
|
||||
aiResponseLength.updatedAt,
|
||||
activePolishStyleId.updatedAt,
|
||||
llmThinkingEnabled.updatedAt,
|
||||
clipboardHistoryEnabled.updatedAt,
|
||||
clipboardCandidateBarEnabled.updatedAt,
|
||||
flowSkipAppSwitch.updatedAt,
|
||||
flowInactivityDuration.updatedAt,
|
||||
].max() ?? .distantPast
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(schemaVersion, forKey: .schemaVersion)
|
||||
try container.encode(providerId, forKey: .providerId)
|
||||
try container.encode(baseURL, forKey: .baseURL)
|
||||
try container.encode(model, forKey: .model)
|
||||
try container.encode(asrProviderId, forKey: .asrProviderId)
|
||||
try container.encode(asrBaseURL, forKey: .asrBaseURL)
|
||||
try container.encode(asrModel, forKey: .asrModel)
|
||||
try container.encode(modeId, forKey: .modeId)
|
||||
try container.encode(localeId, forKey: .localeId)
|
||||
try container.encode(engineMode, forKey: .engineMode)
|
||||
try container.encode(hasAcknowledgedCloudSharing, forKey: .hasAcknowledgedCloudSharing)
|
||||
try container.encode(uiLanguage, forKey: .uiLanguage)
|
||||
try container.encode(translationTargetLocaleId, forKey: .translationTargetLocaleId)
|
||||
try container.encode(handednessPreference, forKey: .handednessPreference)
|
||||
try container.encode(cursorDragNavigationEnabled, forKey: .cursorDragNavigationEnabled)
|
||||
try container.encode(keyboardHapticIntensity, forKey: .keyboardHapticIntensity)
|
||||
try container.encode(polishIntensity, forKey: .polishIntensity)
|
||||
try container.encode(aiResponseLength, forKey: .aiResponseLength)
|
||||
try container.encode(activePolishStyleId, forKey: .activePolishStyleId)
|
||||
try container.encode(llmThinkingEnabled, forKey: .llmThinkingEnabled)
|
||||
try container.encode(flowSkipAppSwitch, forKey: .flowSkipAppSwitch)
|
||||
try container.encode(flowInactivityDuration, forKey: .flowInactivityDuration)
|
||||
}
|
||||
}
|
||||
|
||||
public extension SyncedAppSettingsV2 {
|
||||
@@ -311,8 +319,6 @@ public extension SyncedAppSettingsV2 {
|
||||
aiResponseLength: field(configuration.aiResponseLength),
|
||||
activePolishStyleId: field(configuration.activePolishStyleId),
|
||||
llmThinkingEnabled: field(configuration.llmThinkingEnabled),
|
||||
clipboardHistoryEnabled: field(configuration.clipboardHistoryEnabled),
|
||||
clipboardCandidateBarEnabled: field(configuration.clipboardCandidateBarEnabled),
|
||||
flowSkipAppSwitch: field(configuration.flowSkipAppSwitch),
|
||||
flowInactivityDuration: field(configuration.flowInactivityDuration)
|
||||
)
|
||||
@@ -345,8 +351,6 @@ public extension SyncedAppSettingsV2 {
|
||||
aiResponseLength: field(AIResponseLength.default),
|
||||
activePolishStyleId: field(PolishStylePackCatalog.defaultID),
|
||||
llmThinkingEnabled: field(false),
|
||||
clipboardHistoryEnabled: field(false),
|
||||
clipboardCandidateBarEnabled: field(false),
|
||||
flowSkipAppSwitch: field(legacy.flowSkipAppSwitch),
|
||||
flowInactivityDuration: field(legacy.flowInactivityDuration)
|
||||
)
|
||||
@@ -394,14 +398,6 @@ public extension SyncedAppSettingsV2 {
|
||||
remote: remote.activePolishStyleId
|
||||
),
|
||||
llmThinkingEnabled: .merge(local: local.llmThinkingEnabled, remote: remote.llmThinkingEnabled),
|
||||
clipboardHistoryEnabled: .merge(
|
||||
local: local.clipboardHistoryEnabled,
|
||||
remote: remote.clipboardHistoryEnabled
|
||||
),
|
||||
clipboardCandidateBarEnabled: .merge(
|
||||
local: local.clipboardCandidateBarEnabled,
|
||||
remote: remote.clipboardCandidateBarEnabled
|
||||
),
|
||||
flowSkipAppSwitch: .merge(local: local.flowSkipAppSwitch, remote: remote.flowSkipAppSwitch),
|
||||
flowInactivityDuration: .merge(
|
||||
local: local.flowInactivityDuration,
|
||||
@@ -430,8 +426,6 @@ public extension SyncedAppSettingsV2 {
|
||||
configuration.aiResponseLength = aiResponseLength.value
|
||||
configuration.activePolishStyleId = activePolishStyleId.value
|
||||
configuration.llmThinkingEnabled = llmThinkingEnabled.value
|
||||
configuration.clipboardHistoryEnabled = clipboardHistoryEnabled.value
|
||||
configuration.clipboardCandidateBarEnabled = clipboardCandidateBarEnabled.value
|
||||
configuration.flowSkipAppSwitch = flowSkipAppSwitch.value
|
||||
configuration.flowInactivityDuration = flowInactivityDuration.value
|
||||
}
|
||||
@@ -462,8 +456,6 @@ public extension SyncedAppSettingsV2 {
|
||||
patch(©.aiResponseLength, value: configuration.aiResponseLength)
|
||||
patch(©.activePolishStyleId, value: configuration.activePolishStyleId)
|
||||
patch(©.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
|
||||
patch(©.clipboardHistoryEnabled, value: configuration.clipboardHistoryEnabled)
|
||||
patch(©.clipboardCandidateBarEnabled, value: configuration.clipboardCandidateBarEnabled)
|
||||
patch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
|
||||
patch(©.flowInactivityDuration, value: configuration.flowInactivityDuration)
|
||||
return copy
|
||||
@@ -497,8 +489,6 @@ public extension SyncedAppSettingsV2 {
|
||||
touch(©.aiResponseLength, value: configuration.aiResponseLength)
|
||||
touch(©.activePolishStyleId, value: configuration.activePolishStyleId)
|
||||
touch(©.llmThinkingEnabled, value: configuration.llmThinkingEnabled)
|
||||
touch(©.clipboardHistoryEnabled, value: configuration.clipboardHistoryEnabled)
|
||||
touch(©.clipboardCandidateBarEnabled, value: configuration.clipboardCandidateBarEnabled)
|
||||
touch(©.flowSkipAppSwitch, value: configuration.flowSkipAppSwitch)
|
||||
touch(©.flowInactivityDuration, value: configuration.flowInactivityDuration)
|
||||
return copy
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// Catalog of target languages the translation feature can produce.
|
||||
//
|
||||
// Kept deliberately small (~10 entries) to match the kind of choices
|
||||
// the user makes in the Settings picker / keyboard chip. We don't try
|
||||
// the user makes in the Settings picker / keyboard menu. We don't try
|
||||
// to expose every BCP-47 locale — the prompt just needs a target
|
||||
// language name, and a curated list reads better than a 100-row scroll.
|
||||
//
|
||||
@@ -30,8 +30,7 @@ public struct TranslationLanguage: Identifiable, Hashable, Sendable {
|
||||
public enum TranslationLanguageCatalog {
|
||||
/// Sentinel id for "don't translate" — the default selection in the
|
||||
/// picker. Picked over an `Optional<TranslationLanguage>` so the
|
||||
/// single-row `Picker` binding stays a plain `String` (and the same
|
||||
/// code path also works for the `TranslationChip` Menu).
|
||||
/// single-row `Picker` binding and keyboard menu stay a plain `String`.
|
||||
public static let offLocaleId = "off"
|
||||
/// Default target language id used on fresh installs when translation
|
||||
/// is enabled. The picker still defaults to `offLocaleId` — this is
|
||||
@@ -39,7 +38,7 @@ public enum TranslationLanguageCatalog {
|
||||
/// recovered without a remembered target.
|
||||
public static let defaultLocaleId = "en"
|
||||
|
||||
/// Curated set. Order matters — the picker / chip render top-to-
|
||||
/// Curated set. Order matters — the picker / menu render top-to-
|
||||
/// bottom, with `offLocaleId` ("不翻译") at the very top so the
|
||||
/// "turn off" action is one tap away from any enabled state.
|
||||
public static let all: [TranslationLanguage] = [
|
||||
|
||||
@@ -41,6 +41,38 @@ public enum TypingInputSchema: String, CaseIterable, Identifiable, Codable, Send
|
||||
}
|
||||
}
|
||||
|
||||
/// Default keyboard open mode when "remember last" is off.
|
||||
public enum DefaultInputMode: String, CaseIterable, Identifiable, Codable, Sendable {
|
||||
case voice
|
||||
case pinyin
|
||||
case english
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
public var labelKey: String {
|
||||
switch self {
|
||||
case .voice: return "settings.typingInput.default.mode.voice"
|
||||
case .pinyin: return "settings.typingInput.default.mode.pinyin"
|
||||
case .english: return "settings.typingInput.default.mode.english"
|
||||
}
|
||||
}
|
||||
|
||||
public var surface: KeyboardState.Surface {
|
||||
switch self {
|
||||
case .voice: return .voice
|
||||
case .pinyin, .english: return .typing
|
||||
}
|
||||
}
|
||||
|
||||
public var typingLanguage: TypingInputLanguage? {
|
||||
switch self {
|
||||
case .voice: return nil
|
||||
case .pinyin: return .chinese
|
||||
case .english: return .english
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum PinyinFuzzyPair: String, CaseIterable, Identifiable, Codable, Sendable {
|
||||
case zhZ
|
||||
case chC
|
||||
@@ -84,9 +116,12 @@ public final class TypingInputConfiguration: ObservableObject {
|
||||
private enum Key {
|
||||
static let schema = "typing.input.schema"
|
||||
static let fuzzyPairs = "typing.input.fuzzyPairs"
|
||||
/// Legacy bool; migrated into `defaultInputMode` (true → pinyin).
|
||||
static let defaultToTyping = "typing.input.defaultToTyping"
|
||||
static let defaultInputMode = "typing.input.defaultInputMode"
|
||||
static let rememberLastSurface = "typing.input.rememberLastSurface"
|
||||
static let lastSurface = "typing.input.lastSurface"
|
||||
static let lastTypingLanguage = "typing.input.lastTypingLanguage"
|
||||
static let resourceVersion = "typing.rime.resourceVersion"
|
||||
static let personalDictionaryFingerprint = "typing.rime.personalDictionaryFingerprint"
|
||||
}
|
||||
@@ -102,13 +137,13 @@ public final class TypingInputConfiguration: ObservableObject {
|
||||
didSet { persistIfReady() }
|
||||
}
|
||||
|
||||
/// Selects the text keyboard whenever the extension becomes visible.
|
||||
/// Ignored when `rememberLastSurface` is on and a prior surface was saved.
|
||||
@Published public var defaultToTyping: Bool {
|
||||
/// Static open preference when `rememberLastSurface` is off.
|
||||
/// Ignored when remembering and a prior surface was saved.
|
||||
@Published public var defaultInputMode: DefaultInputMode {
|
||||
didSet { persistIfReady() }
|
||||
}
|
||||
|
||||
/// When on, reopen on the voice/typing surface left at the last dismiss.
|
||||
/// When on, reopen on the voice/typing/AI surface (and typing language) left last time.
|
||||
@Published public var rememberLastSurface: Bool {
|
||||
didSet { persistIfReady() }
|
||||
}
|
||||
@@ -119,7 +154,7 @@ public final class TypingInputConfiguration: ObservableObject {
|
||||
schema = TypingInputSchema(rawValue: schemaId) ?? .fullPinyin
|
||||
let fuzzyIds = self.defaults.stringArray(forKey: Key.fuzzyPairs) ?? []
|
||||
fuzzyPairs = Set(fuzzyIds.compactMap(PinyinFuzzyPair.init(rawValue:)))
|
||||
defaultToTyping = self.defaults.bool(forKey: Key.defaultToTyping)
|
||||
defaultInputMode = Self.resolveDefaultInputMode(from: self.defaults)
|
||||
rememberLastSurface = self.defaults.bool(forKey: Key.rememberLastSurface)
|
||||
isHydrating = false
|
||||
}
|
||||
@@ -142,16 +177,16 @@ public final class TypingInputConfiguration: ObservableObject {
|
||||
?? .fullPinyin
|
||||
let fuzzyIds = defaults.stringArray(forKey: Key.fuzzyPairs) ?? []
|
||||
fuzzyPairs = Set(fuzzyIds.compactMap(PinyinFuzzyPair.init(rawValue:)))
|
||||
defaultToTyping = defaults.bool(forKey: Key.defaultToTyping)
|
||||
defaultInputMode = Self.resolveDefaultInputMode(from: defaults)
|
||||
rememberLastSurface = defaults.bool(forKey: Key.rememberLastSurface)
|
||||
isHydrating = false
|
||||
}
|
||||
|
||||
/// Legacy helper for the default-to-typing toggle only (not full open policy).
|
||||
/// Legacy helper: true when the static default opens on the typing surface.
|
||||
nonisolated public static func prefersTypingOnOpen(
|
||||
defaults: UserDefaults? = nil
|
||||
) -> Bool {
|
||||
(defaults ?? AppGroup.defaultsIfAvailable)?.bool(forKey: Key.defaultToTyping) ?? false
|
||||
resolveDefaultInputMode(from: defaults ?? AppGroup.defaultsIfAvailable).surface == .typing
|
||||
}
|
||||
|
||||
nonisolated public static func remembersLastSurface(
|
||||
@@ -161,26 +196,43 @@ public final class TypingInputConfiguration: ObservableObject {
|
||||
}
|
||||
|
||||
/// Surface to show on the first frame of a keyboard presentation.
|
||||
/// Prefer last-left surface when remembering; otherwise default-to-typing.
|
||||
/// Prefer last-left surface when remembering; otherwise default input mode.
|
||||
nonisolated public static func preferredSurfaceOnOpen(
|
||||
defaults: UserDefaults? = nil
|
||||
) -> KeyboardState.Surface {
|
||||
preferredOpenPreference(defaults: defaults).surface
|
||||
}
|
||||
|
||||
/// Typing language to apply when opening onto the typing surface.
|
||||
nonisolated public static func preferredTypingLanguageOnOpen(
|
||||
defaults: UserDefaults? = nil
|
||||
) -> TypingInputLanguage? {
|
||||
preferredOpenPreference(defaults: defaults).typingLanguage
|
||||
}
|
||||
|
||||
nonisolated public static func preferredOpenPreference(
|
||||
defaults: UserDefaults? = nil
|
||||
) -> (surface: KeyboardState.Surface, typingLanguage: TypingInputLanguage?) {
|
||||
let store = defaults ?? AppGroup.defaultsIfAvailable
|
||||
guard let store else { return .voice }
|
||||
guard let store else { return (.voice, nil) }
|
||||
|
||||
// AI is an explicit product surface. Restore it as an empty temporary
|
||||
// conversation even when the general "remember surface" toggle is off.
|
||||
if store.string(forKey: Key.lastSurface) == KeyboardState.Surface.ai.rawValue {
|
||||
return .ai
|
||||
return (.ai, nil)
|
||||
}
|
||||
|
||||
if store.bool(forKey: Key.rememberLastSurface),
|
||||
let raw = store.string(forKey: Key.lastSurface),
|
||||
let surface = KeyboardState.Surface(rawValue: raw) {
|
||||
return surface
|
||||
let language: TypingInputLanguage? = surface == .typing
|
||||
? persistedTypingLanguage(defaults: store) ?? .chinese
|
||||
: nil
|
||||
return (surface, language)
|
||||
}
|
||||
|
||||
return store.bool(forKey: Key.defaultToTyping) ? .typing : .voice
|
||||
let mode = resolveDefaultInputMode(from: store)
|
||||
return (mode.surface, mode.typingLanguage)
|
||||
}
|
||||
|
||||
/// Persist the surface present when the keyboard leaves the screen.
|
||||
@@ -191,6 +243,15 @@ public final class TypingInputConfiguration: ObservableObject {
|
||||
(defaults ?? AppGroup.defaultsIfAvailable)?.set(surface.rawValue, forKey: Key.lastSurface)
|
||||
}
|
||||
|
||||
/// Persist the typing language left on the typing surface.
|
||||
nonisolated public static func persistLastTypingLanguage(
|
||||
_ language: TypingInputLanguage,
|
||||
defaults: UserDefaults? = nil
|
||||
) {
|
||||
(defaults ?? AppGroup.defaultsIfAvailable)?
|
||||
.set(language.rawValue, forKey: Key.lastTypingLanguage)
|
||||
}
|
||||
|
||||
nonisolated public static func installedResourceVersion(
|
||||
defaults: UserDefaults? = nil
|
||||
) -> String? {
|
||||
@@ -219,11 +280,32 @@ public final class TypingInputConfiguration: ObservableObject {
|
||||
.set(value, forKey: Key.personalDictionaryFingerprint)
|
||||
}
|
||||
|
||||
nonisolated private static func resolveDefaultInputMode(
|
||||
from defaults: UserDefaults?
|
||||
) -> DefaultInputMode {
|
||||
guard let defaults else { return .voice }
|
||||
if let raw = defaults.string(forKey: Key.defaultInputMode),
|
||||
let mode = DefaultInputMode(rawValue: raw) {
|
||||
return mode
|
||||
}
|
||||
// Migrate legacy toggle: on → pinyin, off → voice.
|
||||
return defaults.bool(forKey: Key.defaultToTyping) ? .pinyin : .voice
|
||||
}
|
||||
|
||||
nonisolated private static func persistedTypingLanguage(
|
||||
defaults: UserDefaults
|
||||
) -> TypingInputLanguage? {
|
||||
guard let raw = defaults.string(forKey: Key.lastTypingLanguage) else { return nil }
|
||||
return TypingInputLanguage(rawValue: raw)
|
||||
}
|
||||
|
||||
private func persistIfReady() {
|
||||
guard !isHydrating else { return }
|
||||
defaults.set(schema.rawValue, forKey: Key.schema)
|
||||
defaults.set(fuzzyPairs.map(\.rawValue).sorted(), forKey: Key.fuzzyPairs)
|
||||
defaults.set(defaultToTyping, forKey: Key.defaultToTyping)
|
||||
defaults.set(defaultInputMode.rawValue, forKey: Key.defaultInputMode)
|
||||
// Keep legacy bool in sync for any older readers still checking it.
|
||||
defaults.set(defaultInputMode.surface == .typing, forKey: Key.defaultToTyping)
|
||||
defaults.set(rememberLastSurface, forKey: Key.rememberLastSurface)
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ public struct VolcengineASRFields: Sendable, Equatable {
|
||||
public static let fixedResourceID = CloudASRModelCatalog.volcengineDefaultResourceID
|
||||
|
||||
public init(
|
||||
authMode: VolcengineASRAuthMode = .appToken,
|
||||
authMode: VolcengineASRAuthMode = .apiKey,
|
||||
appID: String = "",
|
||||
accessToken: String = "",
|
||||
apiKeyCredential: String = ""
|
||||
@@ -148,7 +148,7 @@ public struct VolcengineASRFields: Sendable, Equatable {
|
||||
// Legacy JSON without auth_mode: prefer app-token when present.
|
||||
if hasAppToken { return .appToken }
|
||||
if hasAPIKey { return .apiKey }
|
||||
return .appToken
|
||||
return .apiKey
|
||||
}
|
||||
|
||||
private static func string(_ json: [String: Any], keys: [String]) -> String? {
|
||||
|
||||
Reference in New Issue
Block a user