feat: polish scenarios and stabilize keyboard layout height

Add preset-driven polish scenarios (Settings, onboarding, ScenarioChip)
with ScenarioPrompt and style directives; drive keyboard height from
content (240pt) and use viewIsAppearing encapsulated-height offset for
smoother keyboard switches; remove redundant StatusBadge and hide system
dictation via hasDictationKey.
This commit is contained in:
Rocky
2026-06-29 00:08:00 +08:00
parent 4fec0da7f0
commit 1bdb8824ac
32 changed files with 1247 additions and 340 deletions
@@ -0,0 +1,55 @@
// PolishScenario.swift
// OSGKeyboard · Shared
//
// Curated polish scenarios the user picks instead of editing a raw
// system prompt. Display names live in Shared.strings; prompt bodies
// are built by `ScenarioPrompt.make`.
import Foundation
public struct PolishScenario: Identifiable, Hashable, Sendable {
public let id: String
/// Shared.strings key for the picker label.
public let labelKey: String
/// Shared.strings key for the compact keyboard chip label.
public let chipLabelKey: String
public init(id: String, labelKey: String, chipLabelKey: String) {
self.id = id
self.labelKey = labelKey
self.chipLabelKey = chipLabelKey
}
}
public enum PolishScenarioCatalog {
public static let defaultId = "daily_chat"
public static let customId = "custom"
/// Order matters picker / chip render top-to-bottom.
public static let all: [PolishScenario] = [
PolishScenario(id: "daily_chat", labelKey: "polishScenario.daily_chat", chipLabelKey: "polishScenario.chip.daily_chat"),
PolishScenario(id: "social_lifestyle", labelKey: "polishScenario.social_lifestyle", chipLabelKey: "polishScenario.chip.social_lifestyle"),
PolishScenario(id: "social_short", labelKey: "polishScenario.social_short", chipLabelKey: "polishScenario.chip.social_short"),
PolishScenario(id: "goofy", labelKey: "polishScenario.goofy", chipLabelKey: "polishScenario.chip.goofy"),
PolishScenario(id: "work", labelKey: "polishScenario.work", chipLabelKey: "polishScenario.chip.work"),
PolishScenario(id: "document", labelKey: "polishScenario.document", chipLabelKey: "polishScenario.chip.document"),
PolishScenario(id: "todo", labelKey: "polishScenario.todo", chipLabelKey: "polishScenario.chip.todo"),
PolishScenario(id: customId, labelKey: "polishScenario.custom", chipLabelKey: "polishScenario.chip.custom"),
]
public static func isCustom(_ id: String) -> Bool {
id == customId
}
public static func resolve(_ id: String) -> PolishScenario {
all.first { $0.id == id } ?? all.first { $0.id == defaultId } ?? all[0]
}
public static func displayName(for id: String, language: AppUILanguage? = nil) -> String {
SharedL10n.string(resolve(id).labelKey, language: language)
}
public static func chipLabel(for id: String, language: AppUILanguage? = nil) -> String {
SharedL10n.string(resolve(id).chipLabelKey, language: language)
}
}
+51 -14
View File
@@ -51,6 +51,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
// 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"
}
@Published public var providerId: String {
@@ -121,7 +122,10 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
/// 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) }
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 {
@@ -146,25 +150,46 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
/// 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) }
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()
}
}
/// v0.2.1 follow-up: with the local engine's translate-and-polish
/// path now real (see `localModeProviderId`), `translationEnabled`
/// alone is enough to decide whether the pipeline should translate.
/// Row visibility (`isTranslationRowVisible`) already gates the UI
/// on engines that can actually run the step, so we don't need to
/// re-check `engineMode` here.
/// 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 {
translationEnabled
guard translationEnabled else { return false }
if isLocalEngine { return localModeCloudPolishEnabled }
return true
}
/// v0.2.1 follow-up: row visibility predicate. Both engines can
/// now run the cloud translate-and-polish step (the local engine
/// routes through DeepSeek via `localModeProviderId`), so the row
/// is shown whenever an engine mode is selected.
/// 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 {
engineMode == "local" || engineMode == "cloud"
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 {
@@ -263,6 +288,17 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
// 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
}
}
// Cloud no longer exposes off/transcribe; migrate legacy values.
if self.engineMode == "cloud", self.modeId != "polish" {
@@ -313,6 +349,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
apiKey = ""
model = preset.defaultModel
systemPrompt = AppGroupStore.defaultSystemPrompt(for: "openai")
polishScenarioId = PolishScenarioCatalog.defaultId
hasAcknowledgedCloudSharing = false
}
}