Remove manual polish scenario UI; rely on auto AppContext only

- Hide AppContext chip and remove manual override from keyboard
- Remove polish scenario picker and custom system prompt from settings
- Remove scenario section from onboarding
- Translation prompt uses auto-detected AppContext instead of polish scenario
- Delete ScenarioPrompt, ScenarioStyleDirective, PolishScenario, and related views

Co-authored-by: Rocky <hkgood@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-07-03 09:26:51 +00:00
parent 74f33b480f
commit 4ab60ba3dc
18 changed files with 31 additions and 871 deletions
-27
View File
@@ -757,35 +757,8 @@ private struct APISetupPage: View {
.padding(.horizontal, Spacing.lg)
}
if config.isPolishScenarioRowVisible {
postProcessingSection
.padding(.horizontal, Spacing.lg)
}
}
.padding(.bottom, Spacing.xxxl)
}
}
/// Polish scenario + optional translation target for cloud onboarding.
private var postProcessingSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
Text("settings.polishScenario.section")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.textCase(.uppercase)
.frame(maxWidth: .infinity, alignment: .leading)
VStack(spacing: 0) {
ScenarioPickerRow(config: config, isVisible: true)
if config.isTranslationRowVisible {
Divider().background(palette.divider)
TranslationPickerRow(config: config, isVisible: true)
}
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
}
}
-59
View File
@@ -1,59 +0,0 @@
// ScenarioPickerRow.swift
// OSGKeyboard · Main App
//
// Single-row polish scenario picker. Maps menu choices to
// `ProviderConfig.polishScenarioId`. Custom scenario uses the existing
// system prompt editor (linked from Settings when selected).
import SwiftUI
import OSGKeyboardShared
struct ScenarioPickerRow: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
var isVisible: Bool = true
var body: some View {
if isVisible {
HStack {
Text("settings.polishScenario.title")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer()
Menu {
ForEach(PolishScenarioCatalog.all) { scenario in
Button {
config.polishScenarioId = scenario.id
} label: {
if config.polishScenarioId == scenario.id {
Label(displayLabel(for: scenario), systemImage: "checkmark")
} else {
Text(displayLabel(for: scenario))
}
}
}
} label: {
HStack(spacing: 6) {
Text(currentLabel)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(palette.textTertiary)
}
}
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
}
}
private var currentLabel: String {
displayLabel(for: PolishScenarioCatalog.resolve(config.polishScenarioId))
}
private func displayLabel(for scenario: PolishScenario) -> String {
PolishScenarioCatalog.displayName(for: scenario.id, language: config.uiLanguage)
}
}
+2 -15
View File
@@ -135,22 +135,9 @@ struct SettingsView: View {
set: { config.localeId = $0 }
)
)
if config.isPolishScenarioRowVisible {
if config.isTranslationRowVisible {
Divider().background(palette.divider)
ScenarioPickerRow(config: config, isVisible: true)
if config.engineMode == "cloud", config.isTranslationRowVisible {
Divider().background(palette.divider)
TranslationPickerRow(config: config, isVisible: true)
}
if config.isCustomPolishScenario {
Divider().background(palette.divider)
NavigationLink {
SystemPromptSettingsView(config: config)
} label: {
footerNavigationRow(title: "settings.systemPrompt.edit")
}
.buttonStyle(.plain)
}
TranslationPickerRow(config: config, isVisible: true)
}
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
@@ -1,60 +0,0 @@
// SystemPromptSettingsView.swift
// OSGKeyboard · Main App
//
// Cloud-engine system prompt editor. Reached from Settings when the
// user picks the cloud recognition path.
import SwiftUI
import OSGKeyboardShared
struct SystemPromptSettingsView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var config: ProviderConfig
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text("settings.systemPrompt.hint")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
if config.isCustomPolishScenario {
Text("settings.polishScenario.customHint")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
TextEditor(text: $config.systemPrompt)
.font(TypeStyle.mono)
.scrollContentBackground(.hidden)
.frame(minHeight: 320)
.padding(Spacing.sm)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.md)
}
.background(palette.background.ignoresSafeArea())
.navigationTitle("settings.systemPrompt.title")
.navigationBarTitleDisplayMode(.inline)
.toolbar(.visible, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("common.reset") {
config.systemPrompt = config.defaultSystemPrompt
}
.font(TypeStyle.body)
.foregroundStyle(palette.accent)
}
}
.toolbarBackground(palette.background, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
}
}
+2 -37
View File
@@ -78,7 +78,6 @@ public final class KeyboardViewController: UIInputViewController {
/// Grace period after a chip-side translation write during which the
/// 1 Hz App Group poll must not overwrite `translationTargetLocaleId`.
private var translationConfigProtectedUntil: Date?
private var polishScenarioConfigProtectedUntil: Date?
private var isAwaitingFlowResult = false
private var lastFlowAutoStartAttempt: TimeInterval = 0
private static let flowAutoStartCooldown: TimeInterval = 20
@@ -132,7 +131,6 @@ public final class KeyboardViewController: UIInputViewController {
// from Settings.app or the host app, and the App Group is the
// only thing both processes see consistently.
syncOnboardingStateFromAppGroup()
syncAppContextFromAppGroup()
// Auto-advance past step 3 ("Enable Keyboard") if the user has
// enabled the keyboard in Settings.app while we were away.
// This is the "automatic return from jump" feature: no manual
@@ -185,11 +183,6 @@ public final class KeyboardViewController: UIInputViewController {
// v0.2.1 follow-up: removed `setTranslationEnabled` the chip
// / picker now writes the locale id directly; `enabled` is derived.
state.setTranslationTargetLocaleId = { [weak self] id in self?.persistTranslationTargetLocaleId(id) }
state.setPolishScenarioId = { [weak self] id in self?.persistPolishScenarioId(id) }
// v0.3.0: per-app context override. Writes to the App Group
// (so `PolishingService` reads it on the next take) and mirrors
// into local state.
state.setAppContext = { [weak self] c in self?.persistAppContext(c) }
// v0.3.0: in-keyboard onboarding actions. Persist via the App
// Group so the host app's `ProviderConfig` stays in sync (and
// the next launch of the host app opens at the same page).
@@ -220,14 +213,6 @@ public final class KeyboardViewController: UIInputViewController {
state.onboardingPage = 4
}
// MARK: - App context override (v0.3.0)
private func persistAppContext(_ context: AppContext) {
let store = AppGroupStore()
store.setDetectedAppContext(context, at: Date())
state.appContext = context
}
// MARK: - Permission requests from the extension (v0.3.0)
/// The keyboard extension cannot present `AVAudioSession` /
@@ -333,13 +318,6 @@ public final class KeyboardViewController: UIInputViewController {
state.onboardingPage = store.onboardingPage
}
/// Mirror the detected app context (or the user's last manual
/// override) so the AppContextChip stays in sync after a jump.
private func syncAppContextFromAppGroup() {
let store = AppGroupStore()
state.appContext = store.detectedAppContext?.context ?? .unknown
}
/// If the user has finished the "Enable Keyboard" step (i.e. the
/// keyboard is now in the system list with full access), and the
/// overlay is currently sitting on that step, advance to the API
@@ -389,16 +367,14 @@ public final class KeyboardViewController: UIInputViewController {
private func refreshConfigFromAppGroup() {
persistor.refreshRuntimeFlags(
into: state,
protectTranslationUntil: translationConfigProtectedUntil,
protectPolishScenarioUntil: polishScenarioConfigProtectedUntil
protectTranslationUntil: translationConfigProtectedUntil
)
}
private func refreshFlowSessionState() {
persistor.refreshRuntimeFlags(
into: state,
protectTranslationUntil: translationConfigProtectedUntil,
protectPolishScenarioUntil: polishScenarioConfigProtectedUntil
protectTranslationUntil: translationConfigProtectedUntil
)
consumePendingFlowDeliveryIfNeeded()
@@ -555,10 +531,6 @@ public final class KeyboardViewController: UIInputViewController {
storedCache: store.detectedAppContext
)
store.setDetectedAppContext(context)
// v0.3.0: mirror into local state so the chip updates without
// waiting for the next `viewWillAppear`. The user gets
// immediate visual feedback that the polish tone just changed.
state.appContext = context
}
private func startUtteranceCountdown() {
@@ -871,13 +843,6 @@ public final class KeyboardViewController: UIInputViewController {
persistor.persist(translationTargetLocaleId: resolved)
}
private func persistPolishScenarioId(_ id: String) {
let resolved = PolishScenarioCatalog.resolve(id).id
state.polishScenarioId = resolved
polishScenarioConfigProtectedUntil = Date().addingTimeInterval(2.5)
persistor.persist(polishScenarioId: resolved)
}
// MARK: - Open host app
private func openHostApp(path: String = "settings") {
@@ -40,7 +40,6 @@ public struct AppGroupPersistor {
// startup; `refreshRuntimeFlags` keeps the chip in sync while
// the keyboard stays open.
state.translationTargetLocaleId = store.translationTargetLocaleId
state.polishScenarioId = store.polishScenarioId
state.handednessPreference = store.handednessPreference
state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled
// v0.2.0: iOS `SpeechAnalyzer` is always ready; mirror that
@@ -84,8 +83,7 @@ public struct AppGroupPersistor {
/// a chip selection the user just wrote to the App Group.
public func refreshRuntimeFlags(
into state: KeyboardViewController.State,
protectTranslationUntil: Date? = nil,
protectPolishScenarioUntil: Date? = nil
protectTranslationUntil: Date? = nil
) {
guard AppGroup.isAvailable else { return }
let store = AppGroupStore()
@@ -96,10 +94,6 @@ public struct AppGroupPersistor {
if !shouldProtectTranslation {
state.translationTargetLocaleId = store.translationTargetLocaleId
}
let shouldProtectScenario = protectPolishScenarioUntil.map { Date() < $0 } ?? false
if !shouldProtectScenario {
state.polishScenarioId = store.polishScenarioId
}
state.handednessPreference = store.handednessPreference
// v0.2.0: iOS `SpeechAnalyzer` is always ready. Keep these
// toggles here so the keyboard UI doesn't flicker if the host
@@ -145,9 +139,4 @@ public struct AppGroupPersistor {
guard AppGroup.isAvailable else { return }
AppGroupStore().setTranslationTargetLocaleId(translationTargetLocaleId)
}
public func persist(polishScenarioId: String) {
guard AppGroup.isAvailable else { return }
AppGroupStore().setPolishScenarioId(polishScenarioId)
}
}
-76
View File
@@ -1,76 +0,0 @@
// AppContextChip.swift
// OSGKeyboard · Keyboard Extension
//
// Surfaces the v0.3.0 per-app polish context on the keyboard top
// bar. The LLM prompt already adapts to the detected context (see
// `PolishingService.buildPrompt(for:context:)`), but without a UI
// cue the user has no way to know "I'm currently in code mode"
// and no way to override the heuristic when it guesses wrong.
//
// Tap the chip cycle through the five `AppContext` cases. The new
// value is written to `AppGroupStore.setDetectedAppContext(_:at:)`,
// so the next `PolishingService` call picks it up immediately.
import SwiftUI
import OSGKeyboardShared
struct AppContextChip: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var state: KeyboardViewController.State
var body: some View {
Menu {
ForEach(AppContext.allCases, id: \.self) { context in
Button {
state.setAppContext(context)
} label: {
if context == state.appContext {
Label(menuLabel(for: context), systemImage: "checkmark")
} else {
Text(menuLabel(for: context))
}
}
}
} label: {
label
}
.menuStyle(.button)
.accessibilityLabel(ExtL10n.text("keyboard.appContext.a11y"))
.accessibilityHint(ExtL10n.text("keyboard.appContext.a11yHint"))
}
private var label: some View {
HStack(spacing: 4) {
Image(systemName: iconName(for: state.appContext))
Text(chipText)
Image(systemName: "chevron.down")
.font(.system(size: 8, weight: .bold))
}
.font(TypeStyle.caption2)
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 6)
.frame(minHeight: 28)
.background(palette.surfaceElevated, in: Capsule())
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
}
private var chipText: String {
ExtL10n.string("keyboard.appContext.chip.\(state.appContext.rawValue)")
}
private func menuLabel(for context: AppContext) -> String {
ExtL10n.string("keyboard.appContext.menu.\(context.rawValue)")
}
private func iconName(for context: AppContext) -> String {
switch context {
case .code: return "chevron.left.forwardslash.chevron.right"
case .email: return "envelope"
case .chat: return "bubble.left"
case .document: return "doc.text"
case .unknown: return "questionmark.circle"
}
}
}
+1 -9
View File
@@ -140,15 +140,7 @@ public struct KeyboardRootView: View {
} else {
CloudEngineChip()
}
// Polish scenario and ASR locale are configured in the main-app
// Settings tab only keep the keyboard top bar uncluttered.
if state.hasCompletedOnboarding {
AppContextChip(state: state)
}
// v0.3: always show the translation chip when the active
// engine can run the cloud LLM step off-by-default keeps
// the menu reachable so the user can pick a target language
// without opening Settings.
// App context is auto-detected on each mic press no UI.
if state.isTranslationChipVisible {
TranslationChip(state: state)
}
-59
View File
@@ -1,59 +0,0 @@
// ScenarioChip.swift
// OSGKeyboard · Keyboard Extension
//
// Compact chip on the keyboard top bar for quick polish scenario
// switching. Same Menu pattern as `TranslationChip`.
import SwiftUI
import OSGKeyboardShared
struct ScenarioChip: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var state: KeyboardViewController.State
var body: some View {
Menu {
ForEach(PolishScenarioCatalog.all) { scenario in
Button {
state.setPolishScenarioId(scenario.id)
} label: {
if scenario.id == state.polishScenarioId {
Label(displayLabel(for: scenario), systemImage: "checkmark")
} else {
Text(displayLabel(for: scenario))
}
}
}
} label: {
label
}
.menuStyle(.button)
.accessibilityLabel(ExtL10n.text("keyboard.scenario.a11y"))
.accessibilityHint(ExtL10n.text("keyboard.scenario.a11yHint"))
}
private var label: some View {
HStack(spacing: 4) {
Image(systemName: "text.bubble")
Text(chipText)
Image(systemName: "chevron.down")
.font(.system(size: 8, weight: .bold))
}
.font(TypeStyle.caption2)
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 6)
.frame(minHeight: 28)
.background(palette.surfaceElevated, in: Capsule())
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
}
private var chipText: String {
PolishScenarioCatalog.chipLabel(for: state.polishScenarioId)
}
private func displayLabel(for scenario: PolishScenario) -> String {
PolishScenarioCatalog.displayName(for: scenario.id)
}
}
@@ -1,55 +0,0 @@
// 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)
}
}
@@ -23,7 +23,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
// 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"
@@ -51,7 +50,6 @@ 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"
static let handednessPreference = "config.handednessPreference"
// v0.3.0: how aggressively the LLM should rewrite transcripts.
static let polishIntensity = "config.polishIntensity"
@@ -80,9 +78,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
@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) }
}
@@ -158,14 +153,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
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 {
@@ -192,17 +179,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
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)
}
/// v0.3.0: how aggressively the LLM should rewrite the ASR
/// transcript. Default is `medium` (Typeless-equivalent). The
/// `off` value never calls the LLM equivalent to "transcribe
@@ -249,12 +225,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
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) {
@@ -272,8 +242,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
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"
@@ -307,17 +275,6 @@ 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
}
}
self.handednessPreference = HandednessPreference.fromStored(
resolvedDefaults.string(forKey: Key.handednessPreference)
)
@@ -353,10 +310,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
}
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
@@ -364,13 +317,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
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() {
@@ -379,8 +325,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
baseURL = preset.defaultBaseURL
apiKey = ""
model = preset.defaultModel
systemPrompt = AppGroupStore.defaultSystemPrompt(for: "openai")
polishScenarioId = PolishScenarioCatalog.defaultId
handednessPreference = .left
hasAcknowledgedCloudSharing = false
}
@@ -31,7 +31,6 @@ public struct AppGroupStore: @unchecked Sendable {
static let providerId = "config.providerId"
static let baseURL = "config.baseURL"
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"
@@ -45,7 +44,6 @@ public struct AppGroupStore: @unchecked Sendable {
// the `translationEnabled` Bool accessor below is kept as a
// computed shim for source compatibility.
static let translationTargetLocaleId = "config.translationTargetLocaleId"
static let polishScenarioId = "config.polishScenarioId"
static let handednessPreference = "config.handednessPreference"
// v0.3.0: polish intensity (off / light / medium / heavy).
static let polishIntensity = "config.polishIntensity"
@@ -79,10 +77,6 @@ public struct AppGroupStore: @unchecked Sendable {
defaults.string(forKey: Key.model) ?? LLMProvider.provider(id: providerId).defaultModel
}
public var systemPrompt: String {
defaults.string(forKey: Key.systemPrompt) ?? Self.defaultSystemPrompt(for: providerId)
}
public var modeId: String {
defaults.string(forKey: Key.modeId) ?? "polish"
}
@@ -138,11 +132,6 @@ public struct AppGroupStore: @unchecked Sendable {
?? TranslationLanguageCatalog.offLocaleId
}
public var polishScenarioId: String {
let stored = defaults.string(forKey: Key.polishScenarioId)
return PolishScenarioCatalog.resolve(stored ?? PolishScenarioCatalog.defaultId).id
}
/// Bottom-row key order on the keyboard extension.
public var handednessPreference: HandednessPreference {
HandednessPreference.fromStored(defaults.string(forKey: Key.handednessPreference))
@@ -193,12 +182,6 @@ public struct AppGroupStore: @unchecked Sendable {
AppGroupConfigDarwin.postConfigChanged()
}
public func setPolishScenarioId(_ id: String) {
let resolved = PolishScenarioCatalog.resolve(id).id
defaults.set(resolved, forKey: Key.polishScenarioId)
AppGroupConfigDarwin.postConfigChanged()
}
public func setHandednessPreference(_ preference: HandednessPreference) {
defaults.set(preference.rawValue, forKey: Key.handednessPreference)
AppGroupConfigDarwin.postConfigChanged()
@@ -229,25 +212,6 @@ public struct AppGroupStore: @unchecked Sendable {
return localModeCloudPolishEnabled
}
/// Whether the keyboard top-bar scenario chip should render.
public var isPolishScenarioChipVisible: Bool {
if engineMode == "cloud" { return true }
return localModeCloudPolishEnabled
}
/// System prompt for the polish pipeline honoring scenario selection.
public func resolvedPolishSystemPrompt(providerId: String? = nil) -> String {
if PolishScenarioCatalog.isCustom(polishScenarioId) {
return systemPrompt
}
let pid = providerId ?? self.providerId
return ScenarioPrompt.make(
scenarioId: polishScenarioId,
providerId: pid,
uiLanguage: uiLanguage
)
}
/// Polish vs translate-and-polish for the active pipeline.
public var polishModeForPipeline: PolishingService.PolishMode {
isTranslationEffective
@@ -365,32 +329,4 @@ public struct AppGroupStore: @unchecked Sendable {
model: model
)
}
// MARK: - Defaults
/// Per-provider default system prompt. We bias the prompt by the
/// provider's *primary* language so Chinese LLMs naturally return
/// Chinese for Chinese input, and English LLMs stay terse.
public static func defaultSystemPrompt(for providerId: String) -> String {
switch providerId {
case "zhipu", "moonshot", "qwen", "deepseek":
return """
():
1) ,;
2)
3) "第一…第二…第三…",使 markdown
4) , 1.5 ;()
5) ,
"""
default:
return """
You are a voice-input polishing assistant. The user has spoken informally; rewrite their dictation as clean written text:
1) Preserve the user's original intent and meaning; do not invent facts.
2) Add proper punctuation, capitalization, and paragraph breaks.
3) When the user enumerates items ("first ... second ... third"), output a markdown list.
4) Keep the output concise do not exceed 1.5x the spoken length. Drop filler words (um, uh, like).
5) Output in the same language as the input. No quotes, no explanation, no preamble.
"""
}
}
}
@@ -98,8 +98,6 @@ public final class KeyboardState: ObservableObject {
/// Defaults to `offLocaleId` so the keyboard boots in the "off"
/// state on first install.
@Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId
/// Selected polish scenario mirrored from App Group.
@Published public var polishScenarioId: String = PolishScenarioCatalog.defaultId
/// v0.2.0: mirrored from App Group local engine runs the cloud
/// LLM step only when this is `true`.
@Published public var localModeCloudPolishEnabled: Bool = false
@@ -119,12 +117,6 @@ public final class KeyboardState: ObservableObject {
return true
}
/// Whether the keyboard top-bar polish scenario chip should render.
public var isPolishScenarioChipVisible: Bool {
if isLocalEngine { return localModeCloudPolishEnabled }
return true
}
/// Convenience shorthand used by the pipeline and views.
public var isLocalEngine: Bool { engineMode == "local" }
@@ -139,10 +131,6 @@ public final class KeyboardState: ObservableObject {
/// to render the right page; main-app `ProviderConfig` is the
/// source of truth and the keyboard mirrors it.
@Published public var onboardingPage: Int = 0
/// Per-call context the polish prompt should adapt to. Mirrored
/// from `AppGroupStore.detectedAppContext` on every `viewWillAppear`
/// so the chip stays consistent across the openjump cycle.
@Published public var appContext: AppContext = .unknown
/// `true` when the user tapped something (mic, settings) right
/// before a forced jump to the host app. The keyboard reads this
/// on return and auto-resumes the action so the user does not have
@@ -172,11 +160,6 @@ public final class KeyboardState: ObservableObject {
/// is derived from the locale id, so there's no separate toggle to
/// persist. Wired in `KeyboardViewController.installStateActions`.
public var setTranslationTargetLocaleId: (String) -> Void = { _ in }
public var setPolishScenarioId: (String) -> Void = { _ in }
/// v0.3.0: manually override the auto-detected app context (e.g.
/// when the heuristic guessed wrong). Writes to the App Group so
/// `PolishingService` picks it up on the next take.
public var setAppContext: (AppContext) -> Void = { _ in }
public var advanceOnboarding: () -> Void = {}
public var completeOnboarding: () -> Void = {}
public var requestMicPermission: () -> Void = {}
@@ -200,8 +200,7 @@ public actor PolishingService {
prompt = TranslationPrompt.make(
target: target,
providerId: effectiveProviderId,
scenarioId: store.polishScenarioId,
uiLanguage: store.uiLanguage
appContext: context.appContext
)
}
}
@@ -231,9 +230,8 @@ public actor PolishingService {
/// 7. The transcript to process
/// 8. Output contract
///
/// The Chinese / English split mirrors the existing per-provider
/// default system prompt in `AppGroupStore.defaultSystemPrompt(for:)`
/// so the polish step stays in the user's chosen output language.
/// The Chinese / English split mirrors `shouldUseChineseGuidance` so
/// the polish step stays in the provider's strongest language.
internal func buildPrompt(for text: String, context: PolishContext) -> String {
let dictionary = store.personalDictionary
let dictionaryBlock = dictionary.promptFragment()
@@ -314,10 +312,7 @@ public actor PolishingService {
}
}
/// Mirror `AppGroupStore.defaultSystemPrompt(for:)` Chinese LLM
/// providers get a Chinese prompt, English ones get English.
/// Keeping these aligned avoids the "model answers in the wrong
/// language" failure mode that LLM benchmarks consistently flag.
/// Chinese-native LLM providers get a Chinese prompt, English ones get English.
private func shouldUseChineseGuidance(providerId: String) -> Bool {
switch providerId {
case "zhipu", "moonshot", "qwen", "deepseek":
@@ -1,59 +0,0 @@
// ScenarioPrompt.swift
// OSGKeyboard · Shared
//
// Builds the system prompt for a selected polish scenario. Output
// format rules come from `ScenarioStyleDirective` (shared with
// `TranslationPrompt`).
import Foundation
public enum ScenarioPrompt {
public static func make(
scenarioId: String,
providerId: String,
uiLanguage: AppUILanguage? = nil
) -> String {
let isChineseNative = Self.isChineseNativeProvider(providerId)
let directive = ScenarioStyleDirective.make(
scenarioId: scenarioId,
providerId: providerId,
uiLanguage: uiLanguage
)
return isChineseNative
? """
\(chineseBase)
\(directive)
"""
: """
\(englishBase)
\(directive)
"""
}
private static func isChineseNativeProvider(_ providerId: String) -> Bool {
["zhipu", "moonshot", "qwen", "deepseek"].contains(providerId)
}
private static let chineseBase = """
ASR
:
1) ,;
2) ASR ()
3) ()
4) ;/, 1.5
5) ,; 2
6) ,
"""
private static let englishBase = """
You are a voice-input polishing assistant. The user spoke informally and the transcript may contain ASR noise.
Hard rules:
1) Preserve meaning; do not invent facts; keep the input language.
2) Fix ASR noise (homophone errors, missing characters, broken segmentation).
3) Drop filler words (um, uh, like).
4) Stay concise; if the scenario below does not require lists/sections, do not exceed 1.5x the spoken length.
5) If the scenario requires bullets or paragraph breaks, use that layout; total length may be up to 2x when listing.
6) Output ONLY the polished text. No quotes, no explanation, no preamble.
"""
}
@@ -1,171 +0,0 @@
// ScenarioStyleDirective.swift
// OSGKeyboard · Shared
//
// Shared output-format rules for each polish scenario. Injected into
// both `ScenarioPrompt` (polish-only) and `TranslationPrompt`
// (translate-and-polish) so the two pipelines stay aligned.
//
// Directives emphasize STRUCTURE (bullets, paragraphs, checklists)
// over tone adjectives structure is what users notice on short ASR
// transcripts.
import Foundation
public enum ScenarioStyleDirective {
/// Format rules for the given scenario, written in the provider's
/// primary instruction language (Chinese-native vs English-native).
public static func make(
scenarioId: String,
providerId: String,
uiLanguage: AppUILanguage? = nil
) -> String {
let id = PolishScenarioCatalog.resolve(scenarioId).id
let lang = uiLanguage ?? AppGroupStore().uiLanguage
let isChineseNative = ["zhipu", "moonshot", "qwen", "deepseek"].contains(providerId)
return isChineseNative
? chinese(id: id, uiLanguage: lang)
: english(id: id, uiLanguage: lang)
}
// MARK: - Chinese directives
private static func chinese(id: String, uiLanguage: AppUILanguage) -> String {
let englishPlatformNames = uiLanguage.resolvedLanguageCode() != "zh-Hans"
switch id {
case "work":
return """
:(Slack)
():
- 2 //, markdown- ,;
- 1 : 12 ; +
- action ;;
:,
:
"""
case "todo":
return """
:TODO/
():
- markdown- ,
- ;;
: prose
"""
case "social_lifestyle":
if englishPlatformNames {
return """
:(Social Network)
:
- 2 ;;
- emoji;广;
"""
}
return """
:
:
- 2 ;;
- emoji ;广;
"""
case "social_short":
if englishPlatformNames {
return """
:Instagram caption
:;;
"""
}
return """
:
:;;
"""
case "goofy":
return """
:()
:;
:;(//)
"""
case "document":
return """
:/
:
- ;2
- markdown- ; `##` ()
- ;
"""
case "daily_chat", PolishScenarioCatalog.customId:
fallthrough
default:
return """
:(IM/)
:,;;
:()
"""
}
}
// MARK: - English directives
private static func english(id: String, uiLanguage: AppUILanguage) -> String {
let englishPlatformNames = uiLanguage.resolvedLanguageCode() != "zh-Hans"
switch id {
case "work":
return """
Scenario: workplace message (email, Slack, Teams).
Format (required):
- If there are 2+ distinct items/requests/questions, you MUST use markdown "- " bullets, one per line; never merge into one paragraph.
- Single item only: 12 short sentences; optional greeting + body.
- Clear action per item; polite but not overly formal.
Allowed: list layout instead of forcing a single dense paragraph.
Forbidden: cramming multiple points into one long sentence or prose block.
"""
case "todo":
return """
Scenario: TODO / checklist note.
Format (required):
- Output MUST be markdown "- " bullets, one item per line.
- Each line starts with a verb; one task per line; no greeting, no explanation.
Forbidden: prose paragraphs, filler, background context.
"""
case "social_lifestyle":
if englishPlatformNames {
return """
Scenario: social network lifestyle post.
Format: if 2 sentences, separate paragraphs with blank lines; first person; light emoji ok; no ad-speak; do not invent experiences.
"""
}
return """
Scenario: Xiaohongshu-style lifestyle share.
Format: if 2 sentences, separate paragraphs with blank lines; first person; light emoji ok; no ad-speak; do not invent experiences.
"""
case "social_short":
if englishPlatformNames {
return """
Scenario: short Instagram caption.
Format: concise, punchy opening; high density; keep brief; no invented hashtags or trends.
"""
}
return """
Scenario: Weibo-style short post.
Format: concise, punchy opening; high density; keep brief; no invented hashtags or trends.
"""
case "goofy":
return """
Scenario: playful chat (goofy tone).
Format: natural short sentences; slightly witty wording only.
Forbidden: new facts, invented jokes, forced humor on serious topics (leave/apology/complaint).
"""
case "document":
return """
Scenario: document / long-form notes.
Format: complete sentences; blank lines between topics when 2 themes; use "- " bullets for steps/enumerations; `##` headings only when content is long enough; minimal slang.
"""
case "daily_chat", PolishScenarioCatalog.customId:
fallthrough
default:
return """
Scenario: everyday chat (IM/DM).
Format: natural short sentences like texting; relaxed punctuation; very light colloquial tone ok.
Forbidden: memo/report tone; forced bullets unless the speaker is enumerating.
"""
}
}
}
@@ -3,75 +3,57 @@
//
// Builds the system prompt the LLM sees when the user has the
// translation toggle on. Re-uses the same per-provider "primary
// language" split the polish prompt uses (`AppGroupStore.defaultSystemPrompt`)
// so Chinese-native LLMs (DeepSeek, Qwen, GLM, Moonshot) get a Chinese
// prompt and English-native LLMs (OpenAI) get an English one the LLM
// is most reliable when the instructions are written in its strongest
// language.
// language" split as `PolishingService.buildPrompt` so Chinese-native
// LLMs get a Chinese prompt and English-native LLMs get an English one.
//
// The "translate AND polish" blend is intentional: ASR transcripts are
// noisy (homophone errors, broken segmentation, dropped particles), so
// the prompt asks the model to clean the noise while translating.
// Scenario output format (`ScenarioStyleDirective`) is appended so
// translate-and-polish honours the user's polish scenario choice.
// noisy, so the prompt asks the model to clean the noise while translating.
// Style follows the auto-detected `AppContext` (same as the polish path).
import Foundation
public enum TranslationPrompt {
/// Build the translate-and-polish system prompt.
///
/// - Parameters:
/// - target: target language entry resolved via `TranslationLanguageCatalog`.
/// - providerId: provider preset id (e.g. `"deepseek"`, `"openai"`);
/// drives the language the prompt is written in.
/// - scenarioId: active polish scenario; format rules are shared
/// with the polish-only path via `ScenarioStyleDirective`.
/// - uiLanguage: host-app UI language (platform label variants).
public static func make(
target: TranslationLanguage,
providerId: String,
scenarioId: String = PolishScenarioCatalog.defaultId,
uiLanguage: AppUILanguage? = nil
appContext: AppContext = .unknown
) -> String {
let isChineseNative = ["zhipu", "moonshot", "qwen", "deepseek"].contains(providerId)
let directive = ScenarioStyleDirective.make(
scenarioId: scenarioId,
providerId: providerId,
uiLanguage: uiLanguage
)
let contextGuideline = appContext.polishGuideline
return isChineseNative
? chinesePrompt(target: target, directive: directive)
: englishPrompt(target: target, directive: directive)
? chinesePrompt(target: target, contextGuideline: contextGuideline)
: englishPrompt(target: target, contextGuideline: contextGuideline)
}
// MARK: - Chinese prompt (for DeepSeek / Qwen / GLM / Moonshot)
private static func chinesePrompt(target: TranslationLanguage, directive: String) -> String {
private static func chinesePrompt(target: TranslationLanguage, contextGuideline: String) -> String {
"""
ASR :
1) ();
2) \(target.promptLanguageName),,;
3) ASR (),;
4) ;/, 1.5 ;();
5) ,; 2 ;
6) ,"以下是翻译"
\(directive)
4) ; 1.5 ;();
5) ,"以下是翻译"
\(contextGuideline)
"""
}
// MARK: - English prompt (for OpenAI / OpenAI-compatible non-Chinese)
private static func englishPrompt(target: TranslationLanguage, directive: String) -> String {
private static func englishPrompt(target: TranslationLanguage, contextGuideline: String) -> String {
"""
You are a voice-input translation and polishing assistant. The user has spoken informally and the transcript may contain ASR noise:
1) Identify the input language; if unclear, assume the user wants translation INTO \(target.promptLanguageName);
2) Translate the content INTO \(target.promptLanguageName), preserving meaning; do not invent facts or omit content;
3) Fix ASR noise (homophone errors, missing characters, broken segmentation) so the translation reads naturally;
4) Stay concise; if the scenario below does not require lists/sections, do not exceed 1.5x the spoken length; drop filler words (um, uh, like);
5) If the scenario requires bullets or paragraph breaks, use that layout in the translation; total length may be up to 2x when listing;
6) Output ONLY the translation. No quotes, no preamble, no explanation.
\(directive)
4) Stay concise; do not exceed 1.5x the spoken length; drop filler words (um, uh, like);
5) Output ONLY the translation. No quotes, no preamble, no explanation.
Current input context: \(contextGuideline)
"""
}
}
+3 -50
View File
@@ -476,60 +476,13 @@ final class LLMClientTests: XCTestCase {
XCTAssertEqual(calls, 1)
}
func testResolvedPolishSystemPromptUsesWorkScenario() {
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set("work", forKey: "config.polishScenarioId")
let store = AppGroupStore(defaults: defaults)
let prompt = store.resolvedPolishSystemPrompt(providerId: "openai")
XCTAssertTrue(prompt.localizedCaseInsensitiveContains("workplace"))
}
func testCustomPolishScenarioUsesStoredSystemPrompt() {
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(PolishScenarioCatalog.customId, forKey: "config.polishScenarioId")
defaults.set("MY CUSTOM PROMPT", forKey: "config.systemPrompt")
let store = AppGroupStore(defaults: defaults)
XCTAssertEqual(store.resolvedPolishSystemPrompt(), "MY CUSTOM PROMPT")
}
func testPolishScenarioChipVisibleWhenCloudPolishEnabledLocally() {
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set("local", forKey: "config.engineMode")
defaults.set(true, forKey: "config.localModeCloudPolishEnabled")
let store = AppGroupStore(defaults: defaults)
XCTAssertTrue(store.isPolishScenarioChipVisible)
}
func testTranslationPromptIncludesWorkScenarioBullets() {
func testTranslationPromptIncludesAppContextGuideline() {
let prompt = TranslationPrompt.make(
target: TranslationLanguageCatalog.resolve("en"),
providerId: "openai",
scenarioId: "work"
appContext: .code
)
XCTAssertTrue(prompt.localizedCaseInsensitiveContains("MUST use markdown"))
XCTAssertTrue(prompt.localizedCaseInsensitiveContains("workplace"))
}
func testWorkScenarioPolishPromptRequiresBullets() {
let prompt = ScenarioPrompt.make(scenarioId: "work", providerId: "openai")
XCTAssertTrue(prompt.localizedCaseInsensitiveContains("MUST use markdown"))
}
func testTodoScenarioPolishPromptRequiresChecklist() {
let prompt = ScenarioPrompt.make(scenarioId: "todo", providerId: "deepseek")
XCTAssertTrue(prompt.contains("必须是 markdown"))
XCTAssertTrue(prompt.localizedCaseInsensitiveContains("preserve English identifiers"))
}
}