feat(polish): add configurable style packs

Add shared prompt composition, custom style management, iCloud sync, and native iOS/macOS selection interfaces so users can keep a consistent writing voice across devices.
This commit is contained in:
Rocky
2026-07-26 12:34:43 +08:00
parent e6f99d2744
commit 937ce33f05
29 changed files with 2388 additions and 87 deletions
@@ -40,6 +40,12 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public static let detectedAppContext = "config.detectedAppContext"
public static let detectedAppContextAt = "config.detectedAppContextAt"
public static let personalDictionary = "config.personalDictionary.v1"
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.
public static let legacyPolishScenarioId = "config.polishScenarioId"
public static let legacySystemPrompt = "config.systemPrompt"
/// When true, the main app mirrors the personal dictionary via iCloud KVS.
public static let personalDictionaryICloudSyncEnabled = "config.personalDictionary.iCloudSyncEnabled"
/// When true, the main app mirrors user settings via iCloud KVS.
@@ -82,6 +88,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
/// Enables provider-specific reasoning / thinking controls for polish LLM requests.
public var llmThinkingEnabled: Bool
public var personalDictionary: PersonalDictionary
public var polishStyleCatalog: PolishStyleCatalog
public var activePolishStyleId: String
/// Opt-in iCloud KVS sync for the personal dictionary (main app only).
public var personalDictionaryICloudSyncEnabled: Bool
/// Opt-in iCloud KVS sync for user settings (main app only).
@@ -244,6 +252,9 @@ public struct AppGroupConfiguration: Sendable, Equatable {
polishIntensity: resolvePolishIntensity(from: defaults),
llmThinkingEnabled: defaults.bool(forKey: Keys.llmThinkingEnabled),
personalDictionary: decodePersonalDictionary(from: defaults),
polishStyleCatalog: decodePolishStyleCatalog(from: defaults),
activePolishStyleId: defaults.string(forKey: Keys.activePolishStyleId)
?? PolishStylePackCatalog.defaultID,
personalDictionaryICloudSyncEnabled: {
if defaults.object(forKey: Keys.personalDictionaryICloudSyncEnabled) == nil {
return true
@@ -347,6 +358,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
config.modeId = "polish"
defaults.set("polish", forKey: Keys.modeId)
}
migrateLegacyPolishStyleIfNeeded(configuration: &config, defaults: defaults)
return config
}
@@ -369,12 +381,14 @@ public struct AppGroupConfiguration: Sendable, Equatable {
defaults.set(cursorDragNavigationEnabled, forKey: Keys.cursorDragNavigationEnabled)
defaults.set(polishIntensity.rawValue, forKey: Keys.polishIntensity)
defaults.set(llmThinkingEnabled, forKey: Keys.llmThinkingEnabled)
defaults.set(activePolishStyleId, forKey: Keys.activePolishStyleId)
defaults.set(flowSkipAppSwitch, forKey: Keys.flowSkipAppSwitch)
defaults.set(flowInactivityDuration.rawValue, forKey: Keys.flowInactivityDuration)
defaults.set(localASRCustomLanguageModelEnabled, forKey: Keys.localASRCustomLanguageModelEnabled)
defaults.set(personalDictionaryICloudSyncEnabled, forKey: Keys.personalDictionaryICloudSyncEnabled)
defaults.set(settingsICloudSyncEnabled, forKey: Keys.settingsICloudSyncEnabled)
Self.encodePersonalDictionary(personalDictionary, to: defaults)
Self.encodePolishStyleCatalog(polishStyleCatalog, to: defaults)
}
// MARK: - Private helpers
@@ -421,6 +435,57 @@ public struct AppGroupConfiguration: Sendable, Equatable {
}
}
private static func decodePolishStyleCatalog(from defaults: UserDefaults) -> PolishStyleCatalog {
guard let data = defaults.data(forKey: Keys.polishStyleCatalog) else { return .empty }
do {
return try JSONDecoder().decode(PolishStyleCatalog.self, from: data)
} catch {
OSGLog.config.warning("polishStyleCatalog decode failed: \(error.localizedDescription, privacy: .public)")
return .empty
}
}
private static func encodePolishStyleCatalog(_ catalog: PolishStyleCatalog, to defaults: UserDefaults) {
do {
defaults.set(try JSONEncoder().encode(catalog), forKey: Keys.polishStyleCatalog)
} catch {
OSGLog.config.warning("polishStyleCatalog encode failed: \(error.localizedDescription, privacy: .public)")
}
}
private static func migrateLegacyPolishStyleIfNeeded(
configuration: inout AppGroupConfiguration,
defaults: UserDefaults
) {
guard !defaults.bool(forKey: Keys.polishStylesMigrated) else { return }
defer { defaults.set(true, forKey: Keys.polishStylesMigrated) }
if let legacyPrompt = defaults.string(forKey: Keys.legacySystemPrompt)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!legacyPrompt.isEmpty {
let boundedPrompt = String(legacyPrompt.prefix(PolishStyleLimits.maximumPromptCharacters))
let custom = PolishStylePack(name: "自定义", prompt: boundedPrompt)
if (try? configuration.polishStyleCatalog.upsert(custom)) != nil {
configuration.activePolishStyleId = custom.id
defaults.set(custom.id, forKey: Keys.activePolishStyleId)
encodePolishStyleCatalog(configuration.polishStyleCatalog, to: defaults)
}
return
}
let legacyMappings = [
"daily_chat": "builtin.chat",
"work": "builtin.formal",
"document": "builtin.structured",
"todo": "builtin.structured",
]
if let legacyID = defaults.string(forKey: Keys.legacyPolishScenarioId),
let mappedID = legacyMappings[legacyID] {
configuration.activePolishStyleId = mappedID
defaults.set(mappedID, forKey: Keys.activePolishStyleId)
}
}
/// Read the API key from the Keychain, falling back to a one-time migration from UserDefaults.
static func resolveAPIKey(
defaults: UserDefaults?,