bc77cabb62
Rework the action cluster to a mic-above-bottom-row layout, add left/right handedness setting that swaps delete and return, and keep the screen awake during Flow recording sessions.
369 lines
18 KiB
Swift
369 lines
18 KiB
Swift
// ProviderConfig.swift
|
|
// OSGKeyboard · Shared
|
|
//
|
|
// User's LLM configuration. Persisted in App Group UserDefaults so both
|
|
// the main app and keyboard extension read the same values.
|
|
//
|
|
// `apiKey` is the exception: it lives in the Keychain (see
|
|
// `Keychain.swift`) for at-rest encryption. The first time this struct
|
|
// inits after upgrade, a legacy plaintext value from UserDefaults is
|
|
// migrated to the Keychain and removed from UserDefaults.
|
|
|
|
import Foundation
|
|
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 systemPrompt = "config.systemPrompt"
|
|
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 polishScenarioId = "config.polishScenarioId"
|
|
static let handednessPreference = "config.handednessPreference"
|
|
}
|
|
|
|
@Published public var providerId: String {
|
|
didSet { defaults.set(providerId, forKey: Key.providerId) }
|
|
}
|
|
@Published public var baseURL: String {
|
|
didSet { defaults.set(baseURL, forKey: Key.baseURL) }
|
|
}
|
|
@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 else { return }
|
|
do {
|
|
try Keychain.setAPIKey(apiKey)
|
|
} catch {
|
|
#if DEBUG
|
|
print("⚠️ [OSGKeyboard] Keychain write failed: \(error)")
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
@Published public var model: String {
|
|
didSet { defaults.set(model, forKey: Key.model) }
|
|
}
|
|
@Published public var systemPrompt: String {
|
|
didSet { defaults.set(systemPrompt, forKey: Key.systemPrompt) }
|
|
}
|
|
@Published public var modeId: String {
|
|
didSet { defaults.set(modeId, forKey: Key.modeId) }
|
|
}
|
|
@Published public var localeId: String {
|
|
didSet { defaults.set(localeId, forKey: Key.localeId) }
|
|
}
|
|
/// "local" → on-device ASR only (raw transcript delivery).
|
|
/// "cloud" → ASR + LLM polish (always on; modeId kept for compatibility).
|
|
@Published public var engineMode: String {
|
|
didSet { defaults.set(engineMode, forKey: Key.engineMode) }
|
|
}
|
|
@Published public var hasCompletedOnboarding: Bool {
|
|
didSet {
|
|
defaults.set(hasCompletedOnboarding, forKey: Key.hasCompletedOnboarding)
|
|
if hasCompletedOnboarding {
|
|
onboardingPage = 0
|
|
}
|
|
}
|
|
}
|
|
/// Persisted onboarding step so returning from Settings does not reset progress.
|
|
@Published public var onboardingPage: Int {
|
|
didSet { defaults.set(onboardingPage, forKey: Key.onboardingPage) }
|
|
}
|
|
/// 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()
|
|
}
|
|
}
|
|
/// 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) }
|
|
}
|
|
/// 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
|
|
}
|
|
/// v0.2.1: 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
|
|
/// the user's choice without a host-app round-trip).
|
|
@Published public var translationTargetLocaleId: String {
|
|
didSet {
|
|
defaults.set(translationTargetLocaleId, forKey: Key.translationTargetLocaleId)
|
|
AppGroupConfigDarwin.postConfigChanged()
|
|
}
|
|
}
|
|
/// Selected polish scenario preset (e.g. `daily_chat`, `work`) or
|
|
/// `custom` when the user edits the raw system prompt.
|
|
@Published public var polishScenarioId: String {
|
|
didSet {
|
|
defaults.set(polishScenarioId, forKey: Key.polishScenarioId)
|
|
AppGroupConfigDarwin.postConfigChanged()
|
|
}
|
|
}
|
|
/// 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()
|
|
}
|
|
}
|
|
|
|
/// Whether the pipeline should run translate-and-polish (not just
|
|
/// polish). Cloud engine: any selected target locale. Local engine:
|
|
/// only when cloud polish is also enabled.
|
|
public var isTranslationEffective: Bool {
|
|
guard translationEnabled else { return false }
|
|
if isLocalEngine { return localModeCloudPolishEnabled }
|
|
return true
|
|
}
|
|
|
|
/// Translation picker visibility. Cloud engine: always. Local engine:
|
|
/// only when "Cloud polish after ASR" is on — translation is a
|
|
/// sub-step of that cloud LLM pass, not a standalone feature.
|
|
public var isTranslationRowVisible: Bool {
|
|
if engineMode == "cloud" { return true }
|
|
return isLocalEngine && localModeCloudPolishEnabled
|
|
}
|
|
|
|
/// Polish scenario picker visibility — same gate as translation:
|
|
/// only when the pipeline can run a cloud LLM step.
|
|
public var isPolishScenarioRowVisible: Bool {
|
|
if engineMode == "cloud" { return true }
|
|
return isLocalEngine && localModeCloudPolishEnabled
|
|
}
|
|
|
|
public var isCustomPolishScenario: Bool {
|
|
PolishScenarioCatalog.isCustom(polishScenarioId)
|
|
}
|
|
|
|
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.
|
|
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" }
|
|
|
|
/// Whether a transcript produced by the local engine should be
|
|
/// sent through the cloud LLM polish step before insertion.
|
|
///
|
|
/// v0.2.0: the local engine defaults to ASR-only. When the user
|
|
/// enables "Cloud polish after ASR" (`localModeCloudPolishEnabled`)
|
|
/// we route the transcript through the configured LLM (DeepSeek by
|
|
/// default in local mode) — same `PolishingService` code path the
|
|
/// cloud engine uses.
|
|
///
|
|
/// If the user hasn't entered an API key we can't run the polish
|
|
/// step; callers should check `Keychain.apiKey()` before invoking.
|
|
public var shouldPolishLocalTranscript: Bool {
|
|
isLocalEngine && localModeCloudPolishEnabled
|
|
}
|
|
|
|
/// v0.2.1 follow-up: when the local engine is using the cloud-
|
|
/// polish step, route the call through DeepSeek — cheap, strong
|
|
/// on Chinese, and the right default for the on-device ASR
|
|
/// transcript. Other engines honor the user's configured
|
|
/// `providerId` unchanged so cloud users keep their preferred
|
|
/// vendor (OpenAI / Anthropic / Zhipu / etc).
|
|
public var localModeProviderId: String {
|
|
isLocalEngine ? "deepseek" : providerId
|
|
}
|
|
|
|
/// The system prompt the user *sees* in the editor — fall back to the
|
|
/// provider-aware default from `AppGroupStore` when nothing is set.
|
|
public var defaultSystemPrompt: String {
|
|
AppGroupStore.defaultSystemPrompt(for: providerId)
|
|
}
|
|
|
|
private let defaults: UserDefaults
|
|
|
|
public init(defaults: UserDefaults? = nil) {
|
|
let resolvedDefaults: UserDefaults = defaults ?? (AppGroup.isAvailable ? AppGroup.defaults : .standard)
|
|
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
|
|
|
|
// 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)
|
|
|
|
self.model = resolvedDefaults.string(forKey: Key.model) ?? preset.defaultModel
|
|
self.systemPrompt = resolvedDefaults.string(forKey: Key.systemPrompt)
|
|
?? AppGroupStore.defaultSystemPrompt(for: pid)
|
|
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
|
|
if let storedScenario = resolvedDefaults.string(forKey: Key.polishScenarioId) {
|
|
self.polishScenarioId = PolishScenarioCatalog.resolve(storedScenario).id
|
|
} else {
|
|
let savedPrompt = resolvedDefaults.string(forKey: Key.systemPrompt)
|
|
?? AppGroupStore.defaultSystemPrompt(for: pid)
|
|
if savedPrompt != AppGroupStore.defaultSystemPrompt(for: pid) {
|
|
self.polishScenarioId = PolishScenarioCatalog.customId
|
|
} else {
|
|
self.polishScenarioId = PolishScenarioCatalog.defaultId
|
|
}
|
|
}
|
|
self.handednessPreference = HandednessPreference.fromStored(
|
|
resolvedDefaults.string(forKey: Key.handednessPreference)
|
|
)
|
|
|
|
// Cloud no longer exposes off/transcribe; migrate legacy values.
|
|
if self.engineMode == "cloud", self.modeId != "polish" {
|
|
self.modeId = "polish"
|
|
}
|
|
}
|
|
|
|
/// 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) -> String {
|
|
if let stored = Keychain.apiKey(), !stored.isEmpty {
|
|
return stored
|
|
}
|
|
if let legacy = defaults.string(forKey: Key.apiKeyLegacy),
|
|
!legacy.isEmpty {
|
|
try? Keychain.setAPIKey(legacy)
|
|
defaults.removeObject(forKey: Key.apiKeyLegacy)
|
|
return legacy
|
|
}
|
|
return ""
|
|
}
|
|
|
|
public func apply(preset: LLMProvider) {
|
|
// Capture the *previous* provider id BEFORE we mutate, so the
|
|
// system-prompt reset check below can compare against the actual
|
|
// prior default.
|
|
let oldId = providerId
|
|
providerId = preset.id
|
|
if !preset.defaultBaseURL.isEmpty {
|
|
baseURL = preset.defaultBaseURL
|
|
}
|
|
if !preset.defaultModel.isEmpty {
|
|
model = preset.defaultModel
|
|
}
|
|
// When switching providers, reset the system prompt to the new
|
|
// provider's default — otherwise the user is left editing a
|
|
// Chinese prompt on a US-English model.
|
|
if systemPrompt.isEmpty
|
|
|| systemPrompt == AppGroupStore.defaultSystemPrompt(for: oldId) {
|
|
systemPrompt = AppGroupStore.defaultSystemPrompt(for: preset.id)
|
|
}
|
|
}
|
|
|
|
public func reset() {
|
|
providerId = "openai"
|
|
let preset = LLMProvider.provider(id: "openai")
|
|
baseURL = preset.defaultBaseURL
|
|
apiKey = ""
|
|
model = preset.defaultModel
|
|
systemPrompt = AppGroupStore.defaultSystemPrompt(for: "openai")
|
|
polishScenarioId = PolishScenarioCatalog.defaultId
|
|
handednessPreference = .left
|
|
hasAcknowledgedCloudSharing = false
|
|
}
|
|
}
|