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:
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -135,23 +135,10 @@ struct SettingsView: View {
|
||||
set: { config.localeId = $0 }
|
||||
)
|
||||
)
|
||||
if config.isPolishScenarioRowVisible {
|
||||
Divider().background(palette.divider)
|
||||
ScenarioPickerRow(config: config, isVisible: true)
|
||||
if config.engineMode == "cloud", config.isTranslationRowVisible {
|
||||
if 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 open↔jump 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 件事:可用 1~2 句短段落;必要时「称呼 + 正文」。
|
||||
- 每条 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: 1–2 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)
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user