From 1bdb8824ac745d179552b37e2c7b26ff14a2199b Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:08:00 +0800 Subject: [PATCH] feat: polish scenarios and stabilize keyboard layout height Add preset-driven polish scenarios (Settings, onboarding, ScenarioChip) with ScenarioPrompt and style directives; drive keyboard height from content (240pt) and use viewIsAppearing encapsulated-height offset for smoother keyboard switches; remove redundant StatusBadge and hide system dictation via hasDictationKey. --- CHANGELOG.md | 21 ++ OSGKeyboard/Services/FlowSessionManager.swift | 30 ++- OSGKeyboard/Views/KeyboardPreviewStub.swift | 28 +-- OSGKeyboard/Views/OnboardingView.swift | 50 ++--- OSGKeyboard/Views/ScenarioPickerRow.swift | 59 ++++++ OSGKeyboard/Views/SettingsView.swift | 56 ++--- .../Views/SystemPromptSettingsView.swift | 7 + OSGKeyboard/en.lproj/Localizable.strings | 5 + OSGKeyboard/zh-Hans.lproj/Localizable.strings | 7 +- OSGKeyboardExt/KeyboardViewController.swift | 136 +++++++++--- .../Services/AppGroupPersistor.swift | 29 ++- OSGKeyboardExt/Views/KeyboardRootView.swift | 195 ++++++++---------- OSGKeyboardExt/Views/RecordButton.swift | 17 +- OSGKeyboardExt/Views/ScenarioChip.swift | 59 ++++++ OSGKeyboardExt/Views/TranslationChip.swift | 6 +- OSGKeyboardExt/en.lproj/Keyboard.strings | 8 +- OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings | 10 +- OSGKeyboardShared/Models/PolishScenario.swift | 55 +++++ OSGKeyboardShared/Models/ProviderConfig.swift | 65 ++++-- .../Services/AppGroupConfigDarwin.swift | 22 ++ .../Services/AppGroupStore.swift | 70 +++++++ .../Services/FlowSessionDarwin.swift | 13 +- .../Services/KeyboardState.swift | 30 ++- .../Services/PolishingService.swift | 101 ++++++--- .../Services/PreconfiguredKeys.swift | 4 +- .../Services/ScenarioPrompt.swift | 59 ++++++ .../Services/ScenarioStyleDirective.swift | 171 +++++++++++++++ .../Services/TranslationPrompt.swift | 40 ++-- OSGKeyboardShared/en.lproj/Shared.strings | 20 +- .../zh-Hans.lproj/Shared.strings | 20 +- OSGKeyboardTests/LLMClientTests.swift | 190 +++++++++++++++++ project.yml | 4 +- 32 files changed, 1247 insertions(+), 340 deletions(-) create mode 100644 OSGKeyboard/Views/ScenarioPickerRow.swift create mode 100644 OSGKeyboardExt/Views/ScenarioChip.swift create mode 100644 OSGKeyboardShared/Models/PolishScenario.swift create mode 100644 OSGKeyboardShared/Services/AppGroupConfigDarwin.swift create mode 100644 OSGKeyboardShared/Services/ScenarioPrompt.swift create mode 100644 OSGKeyboardShared/Services/ScenarioStyleDirective.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index be51fda..ad954f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Polish scenarios**: pick a writing context (Daily Chat, Social Network / 小红书, Instagram / 微博, Goofy, Work, Document, TODO, Custom) in Settings, onboarding, and the keyboard top-bar `ScenarioChip`. Presets drive `ScenarioPrompt`; Custom reuses the system prompt editor. + +### Changed +- **Scenario output formats**: shared `ScenarioStyleDirective` enforces structural rules (Work → mandatory bullets for multi-item input, TODO → checklist). The same directive applies to translate-and-polish via `TranslationPrompt`. + +## [0.3.0] - 2026-06-24 + +### Added +- **Post-polish translation** for cloud and local engines: target-language picker in Settings / onboarding, `TranslationChip` on the keyboard top bar, and `PolishMode.translate` in `PolishingService`. +- **Preconfigured DeepSeek key** (`PreconfiguredKeys`) for local-engine cloud polish without round-tripping the Settings API card. + +### Changed +- **Local-engine translation is gated on cloud polish**: the translation row and LLM step are hidden/disabled until "Cloud polish after ASR" is enabled; turning polish off clears a stale translation target. +- **Local-engine LLM endpoint pinning**: when the pipeline routes through DeepSeek, base URL and model come from the DeepSeek preset instead of the user's cloud-provider settings (fixes DeepSeek key + Qwen URL 401s). + +### Fixed +- **Translation chip always visible** when the engine can run cloud LLM (cloud always; local when cloud polish is on) — no longer hidden when target is "不翻译". +- **Keyboard translation menu** first item shows "不翻译"; chip label when off stays "翻译". +- **Translation toggle race**: 2.5s protect window after chip writes, Darwin config notification, host finalize re-reads App Group; turning off cloud polish no longer clears saved translation target. + ## [0.2.1] - 2026-06-24 ### Removed diff --git a/OSGKeyboard/Services/FlowSessionManager.swift b/OSGKeyboard/Services/FlowSessionManager.swift index 7604f3c..be493f0 100644 --- a/OSGKeyboard/Services/FlowSessionManager.swift +++ b/OSGKeyboard/Services/FlowSessionManager.swift @@ -560,11 +560,12 @@ final class FlowSessionManager: ObservableObject { let engineMode = store.engineMode let chunkNote = Self.chunkWarningMessage(chunkWarnings) - let shouldPolish = (engineMode == "cloud") - || (engineMode == "local" && store.localModeCloudPolishEnabled) + // Re-read App Group at finalize so chip-side translation changes + // from the keyboard extension are visible before polish/translate. + let pipelineStore = AppGroupStore() - if !shouldPolish { - // Local engine, cloud-polish toggle off — pure ASR. + if !pipelineStore.shouldRunCloudLLMStep { + // Local engine with cloud polish off — ASR-only. FlowSessionBridge.storeTranscriptionResult(text, polishWarning: chunkNote) FlowDiagnostics.log( "finalize ASR-only total=\(String(format: "%.1f", Date().timeIntervalSince(pipelineStarted)))s " + @@ -580,8 +581,18 @@ final class FlowSessionManager: ObservableObject { var delivered = text let polishStarted = Date() + let polishMode = pipelineStore.polishModeForPipeline + FlowDiagnostics.log( + "finalize LLM mode=\(Self.polishModeLogLabel(polishMode)) " + + "translationTarget=\(pipelineStore.translationTargetLocaleId) " + + "cloudPolish=\(pipelineStore.localModeCloudPolishEnabled)" + ) do { - let polished = try await polisher.polish(text) + let polished = try await polisher.polish( + text, + mode: polishMode, + providerIdOverride: pipelineStore.polishProviderIdOverride + ) delivered = polished FlowSessionBridge.storeTranscriptionResult(polished, polishWarning: chunkNote) FlowDiagnostics.log( @@ -611,6 +622,15 @@ final class FlowSessionManager: ObservableObject { debug("utterance finalized length=\(text.count)") } + private static func polishModeLogLabel(_ mode: PolishingService.PolishMode) -> String { + switch mode { + case .polish: + return "polish" + case .translate(let targetLocaleId): + return "translate(\(targetLocaleId))" + } + } + private static func chunkWarningMessage(_ warnings: [String]) -> String? { guard !warnings.isEmpty else { return nil } return warnings.joined(separator: "\n") diff --git a/OSGKeyboard/Views/KeyboardPreviewStub.swift b/OSGKeyboard/Views/KeyboardPreviewStub.swift index c490fce..44a5a17 100644 --- a/OSGKeyboard/Views/KeyboardPreviewStub.swift +++ b/OSGKeyboard/Views/KeyboardPreviewStub.swift @@ -50,7 +50,7 @@ struct KeyboardPreviewStub: View { .padding(.top, 4) .padding(.bottom, 6) } - .frame(height: 280) + .frame(height: 240) } // MARK: - Top bar @@ -60,7 +60,6 @@ struct KeyboardPreviewStub: View { modeChip localeChip Spacer(minLength: 0) - statusBadge Button(action: openSettings) { Image(systemName: "gearshape.fill") .font(.system(size: 13, weight: .medium)) @@ -149,31 +148,6 @@ struct KeyboardPreviewStub: View { } } - private var statusBadge: some View { - Group { - switch phase { - case .idle: - EmptyView() - case .recording: - HStack(spacing: 4) { - Circle().fill(palette.recordRed).frame(width: 6, height: 6) - Text("keyboard.rec").font(TypeStyle.caption2).foregroundStyle(palette.textSecondary) - } - .padding(.horizontal, Spacing.xs).padding(.vertical, 3) - .background(palette.surface, in: Capsule()) - .overlay(Capsule().stroke(palette.divider, lineWidth: 0.5)) - case .processing: - HStack(spacing: 4) { - Circle().fill(palette.accent).frame(width: 6, height: 6) - Text("···").font(TypeStyle.caption2).foregroundStyle(palette.textSecondary) - } - .padding(.horizontal, Spacing.xs).padding(.vertical, 3) - .background(palette.surface, in: Capsule()) - .overlay(Capsule().stroke(palette.divider, lineWidth: 0.5)) - } - } - } - // MARK: - Centre area private var centreArea: some View { diff --git a/OSGKeyboard/Views/OnboardingView.swift b/OSGKeyboard/Views/OnboardingView.swift index ce115f6..c7a056a 100644 --- a/OSGKeyboard/Views/OnboardingView.swift +++ b/OSGKeyboard/Views/OnboardingView.swift @@ -734,23 +734,6 @@ private struct APISetupPage: View { .padding(.horizontal, Spacing.lg) APISettingsCard(config: config) .padding(.horizontal, Spacing.lg) - // v0.2.1 follow-up: translation row lives on the - // onboarding engine page so first-time users can - // pick a target language before they ever see the - // keyboard. v0.2.1 final review: both engines now - // show the row (the local engine routes the polish - // step through DeepSeek, so the constraint is gone). - // Wrapped in the same surface card chrome as the - // APISettingsCard above for visual symmetry. - // - // v0.2.1 final review (topbar cleanup pass): the - // surface card chrome is owned by `translationSection` - // itself, so both branches get it for free — no - // per-branch duplication. - if config.isTranslationRowVisible { - translationSection - .padding(.horizontal, Spacing.lg) - } } else { // v0.2.0: local engine is iOS `SpeechAnalyzer` only. // Surface the cloud-polish toggle and a one-line @@ -772,38 +755,31 @@ private struct APISetupPage: View { ) } .padding(.horizontal, Spacing.lg) - // v0.2.1 final review: same surface card chrome as - // the cloud branch — the row now renders for both - // engines. - if config.isTranslationRowVisible { - translationSection - .padding(.horizontal, Spacing.lg) - } + } + + if config.isPolishScenarioRowVisible { + postProcessingSection + .padding(.horizontal, Spacing.lg) } } .padding(.bottom, Spacing.xxxl) } } - /// v0.2.1 follow-up: extracted so both engine branches can render - /// the same surface card + picker. `TranslationPickerRow` itself - /// reads `ProviderConfig.translationTargetLocaleId` directly, so - /// picking a locale in onboarding flows through to the keyboard - /// extension on the next `load()` cycle. - /// - /// v0.2.1 final review: the section header now reads - /// `settings.translation.afterPolish` (renamed alongside the row - /// title in `TranslationPickerRow`) so the section and row read - /// as one cohesive group. - private var translationSection: some View { + /// Polish scenario + optional translation target for cloud onboarding. + private var postProcessingSection: some View { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { - Text("settings.translation.afterPolish") + Text("settings.polishScenario.section") .font(TypeStyle.caption2) .foregroundStyle(palette.textSecondary) .textCase(.uppercase) .frame(maxWidth: .infinity, alignment: .leading) VStack(spacing: 0) { - TranslationPickerRow(config: config, isVisible: true) + 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( diff --git a/OSGKeyboard/Views/ScenarioPickerRow.swift b/OSGKeyboard/Views/ScenarioPickerRow.swift new file mode 100644 index 0000000..1ae3ad3 --- /dev/null +++ b/OSGKeyboard/Views/ScenarioPickerRow.swift @@ -0,0 +1,59 @@ +// 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) + } +} diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift index 96f42cc..46cd7db 100644 --- a/OSGKeyboard/Views/SettingsView.swift +++ b/OSGKeyboard/Views/SettingsView.swift @@ -62,6 +62,7 @@ struct SettingsView: View { VStack(spacing: Spacing.md) { appLanguageSection engineSection + languageAndPolishSection // v0.2.1: hide provider/api card when the // local engine is active regardless of the // cloud-polish toggle. Local mode is @@ -73,13 +74,9 @@ struct SettingsView: View { providerSection apiSection } - languageAndModelsSection if config.engineMode == "local" { localEngineSettingsSection } - if config.engineMode == "cloud" { - systemPromptLinkSection - } if presentation == .tab { footerLinks } @@ -124,19 +121,12 @@ struct SettingsView: View { EnginePickerSection(config: config) } - // MARK: - Language & on-device models + // MARK: - Language & polish - private var languageAndModelsSection: some View { + private var languageAndPolishSection: some View { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { - sectionHeader("settings.language.title") + sectionHeader("settings.languageAndPolish.title") VStack(spacing: 0) { - // v0.2.1: language tab reorder — ASR locale ("识别语言") - // now sits above the cloud-polish toggle / local models - // block so the row that maps to microphone input comes - // first, the row that maps to post-processing comes - // second. Translation moved into the local-engine - // group (see `localEngineSettingsSection`) so the local - // engine reads as one cohesive card on its own. LocalePickerRow( locales: effectiveLocales, selection: Binding( @@ -144,6 +134,23 @@ 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 { + 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( @@ -246,27 +253,6 @@ struct SettingsView: View { dynamicLocales = entries } - // MARK: - System prompt (cloud only) - - private var systemPromptLinkSection: some View { - VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { - sectionHeader("settings.systemPrompt.title") - VStack(spacing: 0) { - NavigationLink { - SystemPromptSettingsView(config: config) - } label: { - footerNavigationRow(title: "settings.systemPrompt.edit") - } - .buttonStyle(.plain) - } - .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.large, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) - } - } - // MARK: - Footer links (tab settings only) private var footerLinks: some View { diff --git a/OSGKeyboard/Views/SystemPromptSettingsView.swift b/OSGKeyboard/Views/SystemPromptSettingsView.swift index 8612363..2a7cbf5 100644 --- a/OSGKeyboard/Views/SystemPromptSettingsView.swift +++ b/OSGKeyboard/Views/SystemPromptSettingsView.swift @@ -20,6 +20,13 @@ struct SystemPromptSettingsView: View { .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) diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index 3284ec2..730c1b2 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -108,9 +108,14 @@ "provider.custom" = "Custom"; "settings.api.title" = "API"; "settings.language.title" = "Language"; +"settings.languageAndPolish.title" = "Language & Polish"; // v0.2.1: translation feature "settings.translation.afterPolish" = "Polish then translate"; "settings.translation.off" = "Don't translate"; +"settings.polishScenario.section" = "Polish"; +"settings.polishScenario.title" = "Scenario"; +"settings.polishScenario.hint" = "Pick a scenario to match how you write. Choose Custom to edit the full system prompt."; +"settings.polishScenario.customHint" = "Custom scenario: edit the full system prompt below."; "settings.languageModels.title" = "Language & models"; "settings.localModels.title" = "On-device models"; "settings.localModels.speechRole" = "Speech"; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index bfc7a87..970448d 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -98,7 +98,7 @@ "settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。"; "settings.engine.cloud.title" = "云端识别与润色"; "settings.engine.cloud.subtitle" = "本地转写 + 你配置的 API 润色,文字发往该第三方服务"; -"settings.provider.title" = "提供商"; +"settings.provider.title" = "云端引擎"; "settings.provider.subtitle" = "选择 LLM 提供商。"; "provider.openai" = "OpenAI"; "provider.deepseek" = "DeepSeek"; @@ -108,9 +108,14 @@ "provider.custom" = "自定义"; "settings.api.title" = "接口"; "settings.language.title" = "语言"; +"settings.languageAndPolish.title" = "语言与润色"; // v0.2.1: 翻译功能 "settings.translation.afterPolish" = "润色后翻译"; "settings.translation.off" = "不翻译"; +"settings.polishScenario.section" = "润色"; +"settings.polishScenario.title" = "润色场景"; +"settings.polishScenario.hint" = "选择适合的使用场景。选「自定义」可编辑完整润色指令。"; +"settings.polishScenario.customHint" = "自定义场景:在下方编辑完整润色指令。"; "settings.languageModels.title" = "语言与模型"; "settings.localModels.title" = "本地模型"; "settings.localModels.speechRole" = "语音识别"; diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index 4688074..2472391 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -74,24 +74,36 @@ public final class KeyboardViewController: UIInputViewController { private var wasFlowSessionActive = false private var flowSessionMonitorTask: Task? private var flowSessionDarwinObserver: FlowSessionDarwinObserver? + private var configDarwinObserver: FlowSessionDarwinObserver? + /// 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 + /// Drives the keyboard slot height on `view` (priority 999). + private var keyboardHeightConstraint: NSLayoutConstraint? + /// Runtime value read from `UIView-Encapsulated-Layout-Height` (varies by device). + private var systemEncapsulatedHeight: CGFloat = 228 + + private var targetKeyboardHeight: CGFloat { + KeyboardRootView.totalHeight + } // MARK: - Lifecycle public override func viewDidLoad() { super.viewDidLoad() - // Keyboard extension MUST opt in to self-sizing, otherwise - // our SwiftUI `frame(height:)` is ignored and the keyboard is - // cropped by the system chrome (Spotlight bar, home indicator). - inputView?.allowsSelfSizing = true + installKeyboardHeight() + configureDictationBehavior() installStateActions() installSwiftUI() loadPersistedConfig() consumePendingDictationResultIfNeeded() refreshDictationProgressStateIfNeeded() installFlowSessionDarwinObserver() + installConfigDarwinObserver() refreshFlowSessionState() } @@ -108,6 +120,7 @@ public final class KeyboardViewController: UIInputViewController { public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + configureDictationBehavior() KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess) consumePendingDictationResultIfNeeded() refreshDictationProgressStateIfNeeded() @@ -115,6 +128,17 @@ public final class KeyboardViewController: UIInputViewController { startFlowSessionMonitor() } + public override func viewIsAppearing(_ animated: Bool) { + super.viewIsAppearing(animated) + applyPresentationHeightOffset() + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + // Presentation finished — lock to the true content-driven height. + keyboardHeightConstraint?.constant = targetKeyboardHeight + } + public override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() cancelPipeline() @@ -126,6 +150,14 @@ public final class KeyboardViewController: UIInputViewController { refreshDictationProgressStateIfNeeded() } + // MARK: - System keyboard chrome + + /// Tell iOS this keyboard provides its own dictation entry (centre mic). + /// When `true`, the system dictation key in the bottom-right is not shown. + private func configureDictationBehavior() { + hasDictationKey = true + } + // MARK: - Wiring private func installStateActions() { @@ -141,16 +173,49 @@ public final class KeyboardViewController: UIInputViewController { // v0.2.1 follow-up: removed `setTranslationEnabled` — the chip // / picker only writes the locale id now; `enabled` is derived. state.setTranslationTargetLocaleId = { [weak self] id in self?.persistTranslationTargetLocaleId(id) } + state.setPolishScenarioId = { [weak self] id in self?.persistPolishScenarioId(id) } state.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") } state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") } state.deleteBackward = { [weak self] in self?.textDocumentProxy.deleteBackward() } } + /// Reserve keyboard height on `view`. During presentation iOS adds a + /// private encapsulated height; `viewIsAppearing` applies the community + /// offset trick (target − encapsulated) so the slot lands at `target`. + private func installKeyboardHeight() { + let constraint = view.heightAnchor.constraint( + equalToConstant: targetKeyboardHeight + ) + constraint.priority = UILayoutPriority(999) + constraint.isActive = true + keyboardHeightConstraint = constraint + } + + /// Read the system encapsulated height and prime our constraint so iOS + /// presentation math (custom + encapsulated) equals `targetKeyboardHeight`. + /// See: https://developer.apple.com/forums/thread/799003 + private func applyPresentationHeightOffset() { + if let encapsulated = view.constraints.first(where: { constraint in + constraint.firstItem as? UIView === view + && constraint.firstAttribute == .height + && constraint !== keyboardHeightConstraint + }) { + systemEncapsulatedHeight = encapsulated.constant + } + let primed = targetKeyboardHeight - systemEncapsulatedHeight + keyboardHeightConstraint?.constant = max(0, primed) + } + private func installSwiftUI() { let root = KeyboardRootView(state: state) let host = UIHostingController(rootView: root) host.view.backgroundColor = .clear host.view.translatesAutoresizingMaskIntoConstraints = false + host.view.clipsToBounds = false + // Keep keyboard layout anchored to the top edge across keyboard + // switches — don't let UIHostingController re-inset for safe area. + host.view.insetsLayoutMarginsFromSafeArea = false + host.safeAreaRegions = [] addChild(host) view.addSubview(host.view) NSLayoutConstraint.activate([ @@ -158,11 +223,6 @@ public final class KeyboardViewController: UIInputViewController { host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), host.view.topAnchor.constraint(equalTo: view.topAnchor), host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - // Pin the host view to a fixed height matching KeyboardRootView.totalHeight. - // Without this, iOS lets the system chrome (Spotlight, home - // indicator) bleed into our content. With it, our content area - // is fully reserved and the keyboard feels intentional. - host.view.heightAnchor.constraint(equalToConstant: KeyboardRootView.totalHeight) ]) host.didMove(toParent: self) self.hosting = host @@ -203,8 +263,28 @@ public final class KeyboardViewController: UIInputViewController { flowSessionMonitorTask = nil } + private func installConfigDarwinObserver() { + configDarwinObserver = FlowSessionDarwinObserver( + notificationName: AppGroupConfigDarwin.notificationName + ) { [weak self] in + self?.refreshConfigFromAppGroup() + } + } + + private func refreshConfigFromAppGroup() { + persistor.refreshRuntimeFlags( + into: state, + protectTranslationUntil: translationConfigProtectedUntil, + protectPolishScenarioUntil: polishScenarioConfigProtectedUntil + ) + } + private func refreshFlowSessionState() { - persistor.refreshRuntimeFlags(into: state) + persistor.refreshRuntimeFlags( + into: state, + protectTranslationUntil: translationConfigProtectedUntil, + protectPolishScenarioUntil: polishScenarioConfigProtectedUntil + ) consumePendingFlowDeliveryIfNeeded() let active = FlowSessionBridge.isSessionActive() @@ -515,8 +595,9 @@ public final class KeyboardViewController: UIInputViewController { debug("received transcript length=\(trimmed.count)") awaitingDictationResult = false stopDictationWatchdog() - // Local engine: host app delivers raw ASR transcript; insert as-is. - if state.isLocalEngine { + + let runtimeStore = AppGroupStore() + guard runtimeStore.shouldRunCloudLLMStep else { textDocumentProxy.insertText(trimmed) state.lastTranscript = "" if let warning = delivery.polishWarning { @@ -527,28 +608,13 @@ public final class KeyboardViewController: UIInputViewController { } return } - // Cloud engine: always polish via the configured LLM. + + // Cloud engine, or local engine with cloud polish / translation. state.phase = .processing Task { @MainActor [weak self] in guard let self else { return } - // v0.2.1: pick the polish mode once at task start so a - // mid-flight toggle flip doesn't change the request we - // already sent. `isTranslationEffective` no longer gates on - // `engineMode == "cloud"` — the row visibility predicate - // already keeps the picker honest, and the local engine's - // translate-and-polish path now routes through DeepSeek. - let polishMode: PolishingService.PolishMode = self.state.isTranslationEffective - ? .translate(targetLocaleId: self.state.translationTargetLocaleId) - : .polish - // v0.2.1: local engine routes through DeepSeek for the - // polish / translate step regardless of the user's chosen - // cloud provider — DeepSeek is cheap and strong on - // Chinese, which is the dominant input for the on-device - // ASR transcript. Cloud engine honors the user's own - // provider id by passing `nil`. - let overrideProviderId: String? = self.state.engineMode == "local" - ? "deepseek" - : nil + let polishMode = runtimeStore.polishModeForPipeline + let overrideProviderId = runtimeStore.polishProviderIdOverride do { let polished = try await self.polisher.polish( trimmed, @@ -656,9 +722,17 @@ public final class KeyboardViewController: UIInputViewController { private func persistTranslationTargetLocaleId(_ id: String) { let resolved = TranslationLanguageCatalog.resolve(id).id state.translationTargetLocaleId = resolved + translationConfigProtectedUntil = Date().addingTimeInterval(2.5) 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") { diff --git a/OSGKeyboardExt/Services/AppGroupPersistor.swift b/OSGKeyboardExt/Services/AppGroupPersistor.swift index 6d39e82..1d8e08a 100644 --- a/OSGKeyboardExt/Services/AppGroupPersistor.swift +++ b/OSGKeyboardExt/Services/AppGroupPersistor.swift @@ -40,6 +40,8 @@ public struct AppGroupPersistor { // startup; `refreshRuntimeFlags` keeps the chip in sync while // the keyboard stays open. state.translationTargetLocaleId = store.translationTargetLocaleId + state.polishScenarioId = store.polishScenarioId + state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled // v0.2.0: iOS `SpeechAnalyzer` is always ready; mirror that // into the State flags so downstream consumers see the same // shape they did when the previous Qwen3 stack reported "ready". @@ -75,14 +77,28 @@ public struct AppGroupPersistor { /// Lightweight refresh for flags the host app may update while the /// keyboard stays open (model downloads, engine switches). - public func refreshRuntimeFlags(into state: KeyboardViewController.State) { + /// + /// When `protectTranslationUntil` is in the future, the translation + /// target locale is not overwritten — avoids the 1 Hz poll clobbering + /// a chip selection the user just wrote to the App Group. + public func refreshRuntimeFlags( + into state: KeyboardViewController.State, + protectTranslationUntil: Date? = nil, + protectPolishScenarioUntil: Date? = nil + ) { guard AppGroup.isAvailable else { return } let store = AppGroupStore() state.engineMode = store.engineMode state.localASRBackend = store.localASRBackend - // v0.2.1 follow-up: same as `load` — only the locale is - // persisted, `enabled` is derived. - state.translationTargetLocaleId = store.translationTargetLocaleId + state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled + let shouldProtectTranslation = protectTranslationUntil.map { Date() < $0 } ?? false + if !shouldProtectTranslation { + state.translationTargetLocaleId = store.translationTargetLocaleId + } + let shouldProtectScenario = protectPolishScenarioUntil.map { Date() < $0 } ?? false + if !shouldProtectScenario { + state.polishScenarioId = store.polishScenarioId + } // v0.2.0: iOS `SpeechAnalyzer` is always ready. Keep these // toggles here so the keyboard UI doesn't flicker if the host // app briefly clears them while refactoring. @@ -127,4 +143,9 @@ public struct AppGroupPersistor { guard AppGroup.isAvailable else { return } AppGroupStore().setTranslationTargetLocaleId(translationTargetLocaleId) } + + public func persist(polishScenarioId: String) { + guard AppGroup.isAvailable else { return } + AppGroupStore().setPolishScenarioId(polishScenarioId) + } } \ No newline at end of file diff --git a/OSGKeyboardExt/Views/KeyboardRootView.swift b/OSGKeyboardExt/Views/KeyboardRootView.swift index 5bc287f..4469abc 100644 --- a/OSGKeyboardExt/Views/KeyboardRootView.swift +++ b/OSGKeyboardExt/Views/KeyboardRootView.swift @@ -3,16 +3,17 @@ // // Typeless-inspired keyboard surface. The keyboard is laid out in three // vertical bands, but the entire height is reserved for us — we set -// `inputView.allowsSelfSizing = true` in the view controller so SwiftUI's -// frame is honoured, and we add safe-area insets at the top and bottom so -// the system Spotlight / home-indicator chrome never clips our controls. +// `KeyboardViewController` drives height on `view` (priority 999) and mirrors +// `KeyboardLayoutMetrics.totalHeight` in SwiftUI — see presentation offset +// in `applyPresentationHeightOffset()`. // // ┌───────────────────────────────────────────┐ -// │ [polish] [中] ● ⚙ │ ← top: ~38 pt (+20%) +// │ [polish] [中] ⚙ │ ← header band (top) // │ (transcript preview) │ -// │ │ -// │ (⌫) ◯ mic (↩) │ ← action row: circular -// │ (space) │ flanking buttons +// │ ┊ │ +// │ (⌫) ◯ mic (↩) │ ← action cluster: +// │ (space) │ centred below header +// │ ┊ │ // └───────────────────────────────────────────┘ import SwiftUI @@ -24,8 +25,33 @@ private enum KeyboardLayoutMetrics { static let sideSpaceBarWidth: CGFloat = 19 static let micFlankMinSpacing: CGFloat = 36 static let sideActionStackSpacing: CGFloat = 16 + /// Gap between the top chip row and the transcript / hint line (4 pt → 8 pt, +100%). + static let topBarToTranscriptSpacing: CGFloat = Spacing.xs /// Outer inset for delete / return·space from screen edges (8 pt → 24 pt, +200%). static let sideActionHorizontalInset: CGFloat = Spacing.xs * 3 + + // MARK: - Content-driven keyboard height (single source of truth) + static let outerPaddingTop: CGFloat = 2 + static let outerPaddingBottom: CGFloat = 6 + static let topBarHeight: CGFloat = 38 + static let transcriptLineHeight: CGFloat = 22 + static let actionClusterHeight: CGFloat = 132 + /// Fixed breathing room above/below the mic row (not flexible Spacers). + static let actionClusterVerticalGap: CGFloat = Spacing.md + + static var headerBandHeight: CGFloat { + topBarHeight + topBarToTranscriptSpacing + transcriptLineHeight + } + + /// 2 + 68 + 16 + 132 + 16 + 6 = 240 pt + static var totalHeight: CGFloat { + outerPaddingTop + + headerBandHeight + + actionClusterVerticalGap + + actionClusterHeight + + actionClusterVerticalGap + + outerPaddingBottom + } } public struct KeyboardRootView: View { @@ -37,11 +63,9 @@ public struct KeyboardRootView: View { self.state = state } - /// Total keyboard height. We set the same value as a height-anchor - /// constraint in the view controller so the host UIInputView picks - /// it up. - static let totalHeight: CGFloat = 280 - private static let topBarHeight: CGFloat = 38 + /// Content-driven keyboard height; mirrored on `UIInputViewController.view` + /// in `KeyboardViewController` (see `KeyboardLayoutMetrics.totalHeight`). + static let totalHeight: CGFloat = KeyboardLayoutMetrics.totalHeight private var palette: ThemePalette { colorScheme == .dark ? Palette.dark : Palette.light @@ -49,14 +73,19 @@ public struct KeyboardRootView: View { public var body: some View { VStack(spacing: 0) { - topBar - .frame(height: Self.topBarHeight) + headerBand - centreArea - .frame(maxWidth: .infinity, maxHeight: .infinity) + Color.clear + .frame(height: KeyboardLayoutMetrics.actionClusterVerticalGap) + + micActionRow + .frame(height: KeyboardLayoutMetrics.actionClusterHeight) + + Color.clear + .frame(height: KeyboardLayoutMetrics.actionClusterVerticalGap) } - .padding(.top, 4) - .padding(.bottom, 6) + .padding(.top, KeyboardLayoutMetrics.outerPaddingTop) + .padding(.bottom, KeyboardLayoutMetrics.outerPaddingBottom) // 透明背景:让系统键盘 chrome 透出,不自行铺色(深浅模式一致)。 .background(Color.clear) .frame(height: Self.totalHeight) @@ -64,6 +93,26 @@ public struct KeyboardRootView: View { .environment(\.themePalette, palette) } + /// Top chip row + transcript / hint line. + private var headerBand: some View { + VStack(spacing: KeyboardLayoutMetrics.topBarToTranscriptSpacing) { + topBar + .frame(height: KeyboardLayoutMetrics.topBarHeight) + + TranscriptLine( + phase: state.phase, + transcript: state.lastTranscript, + flowSessionActive: state.flowSessionActive, + isLocalEngine: state.isLocalEngine, + localModelsReady: state.localModelsReady, + localModelsLoaded: state.localModelsLoaded, + openSettings: state.openSettings, + startFlowSession: state.startFlowSession + ) + .frame(height: KeyboardLayoutMetrics.transcriptLineHeight) + } + } + // MARK: - Top bar private var topBar: some View { @@ -73,22 +122,20 @@ public struct KeyboardRootView: View { } else { CloudEngineChip() } + if state.isPolishScenarioChipVisible { + ScenarioChip(state: state) + } LocaleChip(localeId: state.localeId) { newId in state.setLocale(newId) } - // v0.2.1: translation chip — sits next to the locale picker - // and doubles as both the on/off switch and the target- - // language picker (Menu pattern matches LocaleChip so the - // top bar stays visually consistent). - // v0.2.1 final review: only render the chip when translation - // is actually on. Off-by-default keeps the top bar compact - // for users who don't need translation; the menu still lives - // in onboarding so the feature is discoverable. - if state.translationEnabled { + // 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. + if state.isTranslationChipVisible { TranslationChip(state: state) } Spacer(minLength: 0) - StatusBadge(phase: state.phase, onDeviceSupported: state.onDeviceSupported) Button(action: state.openSettings) { Image(systemName: "gearshape.fill") .font(.system(size: 14, weight: .medium)) @@ -103,35 +150,11 @@ public struct KeyboardRootView: View { .padding(.horizontal, Spacing.md) } - // MARK: - Centre area - - private var centreArea: some View { - VStack(spacing: Spacing.xxs) { - TranscriptLine( - phase: state.phase, - transcript: state.lastTranscript, - flowSessionActive: state.flowSessionActive, - isLocalEngine: state.isLocalEngine, - localModelsReady: state.localModelsReady, - localModelsLoaded: state.localModelsLoaded, - openSettings: state.openSettings, - startFlowSession: state.startFlowSession - ) - .frame(height: 22) - - Spacer(minLength: 0) - - micActionRow - .padding(.bottom, Spacing.xs) - - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity) - } + // MARK: - Action cluster /// Delete (left), mic (centre), return + space stacked on the right. - /// HStack vertical alignment keeps delete, mic centre, and the gap - /// between return/space on one horizontal axis. + /// Fixed vertical gaps in `body` keep the cluster centred without + /// flexible Spacers consuming extra keyboard height. private var micActionRow: some View { HStack(alignment: .center, spacing: 0) { CircularToolbarButton(systemName: "delete.left", label: "delete") { @@ -186,19 +209,19 @@ extension KeyboardRootView { #if DEBUG #Preview("Keyboard · Idle") { KeyboardRootView(state: KeyboardViewController.State.previewIdle) - .frame(width: 390, height: 280) + .frame(width: 390, height: KeyboardRootView.totalHeight) .preferredColorScheme(.dark) } #Preview("Keyboard · Recording") { KeyboardRootView(state: KeyboardViewController.State.previewRecording) - .frame(width: 390, height: 280) + .frame(width: 390, height: KeyboardRootView.totalHeight) .preferredColorScheme(.dark) } #Preview("Keyboard · Processing") { KeyboardRootView(state: KeyboardViewController.State.previewProcessing) - .frame(width: 390, height: 280) + .frame(width: 390, height: KeyboardRootView.totalHeight) .preferredColorScheme(.dark) } #endif @@ -367,62 +390,6 @@ private struct CircularToolbarButton: View { } } -// MARK: - Status badge - -private struct StatusBadge: View { - @Environment(\.themePalette) private var palette: ThemePalette - - let phase: KeyboardViewController.State.Phase - /// Reflects whether the active ASR session is on-device. We surface - /// a small ⚠️ during recording so the user knows their audio is - /// going to the cloud for this locale (and so devs catch it during - /// QA without staring at the Xcode console). - let onDeviceSupported: Bool - - var body: some View { - Group { - switch phase { - case .idle: - EmptyView() - case .requestingPermissions: - EmptyView() - case .recording: - if onDeviceSupported { - dot(color: palette.recordRed, labelKey: "keyboard.status.rec") - } else { - dot(color: palette.warning, labelKey: "keyboard.status.recWarning", showWarning: true) - } - case .processing: - dot(color: palette.accent, labelKey: "keyboard.status.processing") - case .error: - dot(color: palette.warning, labelKey: "keyboard.status.error") - case .denied: - dot(color: palette.warning, labelKey: "keyboard.status.error") - } - } - } - - private func dot(color: Color, labelKey: String, showWarning: Bool = false) -> some View { - HStack(spacing: 4) { - Circle() - .fill(color) - .frame(width: 6, height: 6) - if showWarning { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(palette.warning) - } - Text(ExtL10n.string(labelKey)) - .font(TypeStyle.caption2) - .foregroundStyle(palette.textSecondary) - } - .padding(.horizontal, Spacing.xs) - .padding(.vertical, 4) - .background(palette.surface, in: Capsule()) - .overlay(Capsule().stroke(palette.divider, lineWidth: 0.5)) - } -} - // MARK: - Cloud engine chip (cloud always ASR + LLM polish) private struct CloudEngineChip: View { diff --git a/OSGKeyboardExt/Views/RecordButton.swift b/OSGKeyboardExt/Views/RecordButton.swift index d46cd7b..d589698 100644 --- a/OSGKeyboardExt/Views/RecordButton.swift +++ b/OSGKeyboardExt/Views/RecordButton.swift @@ -42,11 +42,20 @@ struct RecordButton: View { return remainingSeconds <= 10 } + /// Decorative rings are sized to stay inside the 132 pt frame applied + /// by `KeyboardRootView` so glow / breath animations are not clipped. + private enum Layout { + static let disc: CGFloat = 104 + static let outerRing: CGFloat = 112 + static let breathRing: CGFloat = 108 + static let glow: CGFloat = 128 + } + var body: some View { ZStack { Circle() .stroke(palette.recordRed.opacity(isUrgent ? 0.55 : 0.35), lineWidth: isUrgent ? 3 : 2) - .frame(width: 150, height: 150) + .frame(width: Layout.breathRing, height: Layout.breathRing) .scaleEffect(breath ? 1.18 : 0.95) .opacity(phase == .recording ? 1 : 0) .animation(Motion.breath, value: breath) @@ -60,7 +69,7 @@ struct RecordButton: View { endRadius: 100 ) ) - .frame(width: 200, height: 200) + .frame(width: Layout.glow, height: Layout.glow) .opacity(phase == .recording ? 0.4 + level * 0.6 : 0) .blur(radius: 18) .animation(Motion.soft, value: phase) @@ -71,7 +80,7 @@ struct RecordButton: View { Color.white.opacity(phase == .idle ? 0.08 : 0.12), lineWidth: 0.5 ) - .frame(width: 140, height: 140) + .frame(width: Layout.outerRing, height: Layout.outerRing) ZStack { Circle() @@ -115,7 +124,7 @@ struct RecordButton: View { } } } - .frame(width: 120, height: 120) + .frame(width: Layout.disc, height: Layout.disc) .animation(Motion.soft, value: phase) .animation(Motion.soft, value: remainingSeconds) } diff --git a/OSGKeyboardExt/Views/ScenarioChip.swift b/OSGKeyboardExt/Views/ScenarioChip.swift new file mode 100644 index 0000000..08864b0 --- /dev/null +++ b/OSGKeyboardExt/Views/ScenarioChip.swift @@ -0,0 +1,59 @@ +// 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) + } +} diff --git a/OSGKeyboardExt/Views/TranslationChip.swift b/OSGKeyboardExt/Views/TranslationChip.swift index 7d45cad..5dcc082 100644 --- a/OSGKeyboardExt/Views/TranslationChip.swift +++ b/OSGKeyboardExt/Views/TranslationChip.swift @@ -19,7 +19,7 @@ // off / on, with the same accent treatment either way. // // Visual states: -// • off → dim outline, "翻译" label +// • off → dim outline, "翻译" chip label (menu first row = "不翻译") // • on (any engine) → accent fill, "→ EN" / "→ 日本語" style label // // Stays in the same visual family as `CloudEngineChip` / `LocaleChip` @@ -88,14 +88,14 @@ struct TranslationChip: View { private func displayLabel(for language: TranslationLanguage) -> String { if language.id == TranslationLanguageCatalog.offLocaleId { - return ExtL10n.string("keyboard.translation.off") + return ExtL10n.string("keyboard.translation.offMenu") } return language.nativeName } private func chipLabel(target: TranslationLanguage, enabled: Bool) -> String { if !enabled { - return ExtL10n.string("keyboard.translation.off") + return ExtL10n.string("keyboard.translation.chip") } // Short form: "→EN" / "→日" style. Falls back to the prompt // language name for languages without a chip-style abbreviation diff --git a/OSGKeyboardExt/en.lproj/Keyboard.strings b/OSGKeyboardExt/en.lproj/Keyboard.strings index a288ad1..58b9e65 100644 --- a/OSGKeyboardExt/en.lproj/Keyboard.strings +++ b/OSGKeyboardExt/en.lproj/Keyboard.strings @@ -176,12 +176,16 @@ "locale.chip.ja-JP" = "日"; "locale.chip.ko-KR" = "韩"; -/* Translation chip (v0.2.1) */ -"keyboard.translation.off" = "Translate"; +/* Translation chip (v0.3) */ +"keyboard.translation.chip" = "Translate"; +"keyboard.translation.offMenu" = "Don't translate"; +"keyboard.translation.off" = "Don't translate"; "keyboard.translation.enable" = "Enable translation"; "keyboard.translation.disable" = "Disable translation"; "keyboard.translation.a11y" = "Translation"; "keyboard.translation.a11yHint" = "Toggle translation or change the target language."; +"keyboard.scenario.a11y" = "Polish scenario"; +"keyboard.scenario.a11yHint" = "Choose how dictation is polished."; /* Mode chip labels (used in both ext + preview stub) */ "mode.off" = "Off"; diff --git a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings index c74a1e1..e8d174d 100644 --- a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings +++ b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings @@ -76,7 +76,7 @@ "settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。"; "settings.engine.cloud.title" = "云端识别与润色"; "settings.engine.cloud.subtitle" = "ASR 转录 + LLM 润色,需要 API Key。"; -"settings.provider.title" = "提供商"; +"settings.provider.title" = "云端引擎"; "settings.provider.subtitle" = "选择 LLM 提供商。"; "settings.api.title" = "接口"; "settings.language.title" = "语言"; @@ -176,12 +176,16 @@ "locale.chip.ja-JP" = "日"; "locale.chip.ko-KR" = "韩"; -/* 翻译 chip (v0.2.1) */ -"keyboard.translation.off" = "翻译"; +/* Translation chip (v0.3) */ +"keyboard.translation.chip" = "翻译"; +"keyboard.translation.offMenu" = "不翻译"; +"keyboard.translation.off" = "不翻译"; "keyboard.translation.enable" = "开启翻译"; "keyboard.translation.disable" = "关闭翻译"; "keyboard.translation.a11y" = "翻译"; "keyboard.translation.a11yHint" = "切换翻译开关或修改目标语言。"; +"keyboard.scenario.a11y" = "润色场景"; +"keyboard.scenario.a11yHint" = "选择润色风格或使用场景。"; /* Mode chip labels */ "mode.off" = "关闭"; diff --git a/OSGKeyboardShared/Models/PolishScenario.swift b/OSGKeyboardShared/Models/PolishScenario.swift new file mode 100644 index 0000000..44042bf --- /dev/null +++ b/OSGKeyboardShared/Models/PolishScenario.swift @@ -0,0 +1,55 @@ +// PolishScenario.swift +// OSGKeyboard · Shared +// +// Curated polish scenarios the user picks instead of editing a raw +// system prompt. Display names live in Shared.strings; prompt bodies +// are built by `ScenarioPrompt.make`. + +import Foundation + +public struct PolishScenario: Identifiable, Hashable, Sendable { + public let id: String + /// Shared.strings key for the picker label. + public let labelKey: String + /// Shared.strings key for the compact keyboard chip label. + public let chipLabelKey: String + + public init(id: String, labelKey: String, chipLabelKey: String) { + self.id = id + self.labelKey = labelKey + self.chipLabelKey = chipLabelKey + } +} + +public enum PolishScenarioCatalog { + public static let defaultId = "daily_chat" + public static let customId = "custom" + + /// Order matters — picker / chip render top-to-bottom. + public static let all: [PolishScenario] = [ + PolishScenario(id: "daily_chat", labelKey: "polishScenario.daily_chat", chipLabelKey: "polishScenario.chip.daily_chat"), + PolishScenario(id: "social_lifestyle", labelKey: "polishScenario.social_lifestyle", chipLabelKey: "polishScenario.chip.social_lifestyle"), + PolishScenario(id: "social_short", labelKey: "polishScenario.social_short", chipLabelKey: "polishScenario.chip.social_short"), + PolishScenario(id: "goofy", labelKey: "polishScenario.goofy", chipLabelKey: "polishScenario.chip.goofy"), + PolishScenario(id: "work", labelKey: "polishScenario.work", chipLabelKey: "polishScenario.chip.work"), + PolishScenario(id: "document", labelKey: "polishScenario.document", chipLabelKey: "polishScenario.chip.document"), + PolishScenario(id: "todo", labelKey: "polishScenario.todo", chipLabelKey: "polishScenario.chip.todo"), + PolishScenario(id: customId, labelKey: "polishScenario.custom", chipLabelKey: "polishScenario.chip.custom"), + ] + + public static func isCustom(_ id: String) -> Bool { + id == customId + } + + public static func resolve(_ id: String) -> PolishScenario { + all.first { $0.id == id } ?? all.first { $0.id == defaultId } ?? all[0] + } + + public static func displayName(for id: String, language: AppUILanguage? = nil) -> String { + SharedL10n.string(resolve(id).labelKey, language: language) + } + + public static func chipLabel(for id: String, language: AppUILanguage? = nil) -> String { + SharedL10n.string(resolve(id).chipLabelKey, language: language) + } +} diff --git a/OSGKeyboardShared/Models/ProviderConfig.swift b/OSGKeyboardShared/Models/ProviderConfig.swift index 6541110..fb7f2e5 100644 --- a/OSGKeyboardShared/Models/ProviderConfig.swift +++ b/OSGKeyboardShared/Models/ProviderConfig.swift @@ -51,6 +51,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { // who upgraded from a build that wrote it don't see a flash of // "on" state during init, but new writes never touch the key. static let translationTargetLocaleId = "config.translationTargetLocaleId" + static let polishScenarioId = "config.polishScenarioId" } @Published public var providerId: String { @@ -121,7 +122,10 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { /// box. Users opt in from Settings when the iOS ASR output isn't /// strong enough (noisy far-field audio, dialectal Chinese, etc.). @Published public var localModeCloudPolishEnabled: Bool { - didSet { defaults.set(localModeCloudPolishEnabled, forKey: Key.localModeCloudPolishEnabled) } + didSet { + defaults.set(localModeCloudPolishEnabled, forKey: Key.localModeCloudPolishEnabled) + AppGroupConfigDarwin.postConfigChanged() + } } /// Host-app UI language. Also mirrored to the App Group for the keyboard extension. @Published public var uiLanguage: AppUILanguage { @@ -146,25 +150,46 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { /// extension can honour it (and so the chip on the keyboard reflects /// the user's choice without a host-app round-trip). @Published public var translationTargetLocaleId: String { - didSet { defaults.set(translationTargetLocaleId, forKey: Key.translationTargetLocaleId) } + didSet { + defaults.set(translationTargetLocaleId, forKey: Key.translationTargetLocaleId) + AppGroupConfigDarwin.postConfigChanged() + } + } + /// Selected polish scenario preset (e.g. `daily_chat`, `work`) or + /// `custom` when the user edits the raw system prompt. + @Published public var polishScenarioId: String { + didSet { + defaults.set(polishScenarioId, forKey: Key.polishScenarioId) + AppGroupConfigDarwin.postConfigChanged() + } } - /// v0.2.1 follow-up: with the local engine's translate-and-polish - /// path now real (see `localModeProviderId`), `translationEnabled` - /// alone is enough to decide whether the pipeline should translate. - /// Row visibility (`isTranslationRowVisible`) already gates the UI - /// on engines that can actually run the step, so we don't need to - /// re-check `engineMode` here. + /// Whether the pipeline should run translate-and-polish (not just + /// polish). Cloud engine: any selected target locale. Local engine: + /// only when cloud polish is also enabled. public var isTranslationEffective: Bool { - translationEnabled + guard translationEnabled else { return false } + if isLocalEngine { return localModeCloudPolishEnabled } + return true } - /// v0.2.1 follow-up: row visibility predicate. Both engines can - /// now run the cloud translate-and-polish step (the local engine - /// routes through DeepSeek via `localModeProviderId`), so the row - /// is shown whenever an engine mode is selected. + /// Translation picker visibility. Cloud engine: always. Local engine: + /// only when "Cloud polish after ASR" is on — translation is a + /// sub-step of that cloud LLM pass, not a standalone feature. public var isTranslationRowVisible: Bool { - engineMode == "local" || engineMode == "cloud" + if engineMode == "cloud" { return true } + return isLocalEngine && localModeCloudPolishEnabled + } + + /// Polish scenario picker visibility — same gate as translation: + /// only when the pipeline can run a cloud LLM step. + public var isPolishScenarioRowVisible: Bool { + if engineMode == "cloud" { return true } + return isLocalEngine && localModeCloudPolishEnabled + } + + public var isCustomPolishScenario: Bool { + PolishScenarioCatalog.isCustom(polishScenarioId) } public var isConfigured: Bool { @@ -263,6 +288,17 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { // conservative default that matches the picker / chip UX). self.translationTargetLocaleId = resolvedDefaults.string(forKey: Key.translationTargetLocaleId) ?? TranslationLanguageCatalog.offLocaleId + if let storedScenario = resolvedDefaults.string(forKey: Key.polishScenarioId) { + self.polishScenarioId = PolishScenarioCatalog.resolve(storedScenario).id + } else { + let savedPrompt = resolvedDefaults.string(forKey: Key.systemPrompt) + ?? AppGroupStore.defaultSystemPrompt(for: pid) + if savedPrompt != AppGroupStore.defaultSystemPrompt(for: pid) { + self.polishScenarioId = PolishScenarioCatalog.customId + } else { + self.polishScenarioId = PolishScenarioCatalog.defaultId + } + } // Cloud no longer exposes off/transcribe; migrate legacy values. if self.engineMode == "cloud", self.modeId != "polish" { @@ -313,6 +349,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { apiKey = "" model = preset.defaultModel systemPrompt = AppGroupStore.defaultSystemPrompt(for: "openai") + polishScenarioId = PolishScenarioCatalog.defaultId hasAcknowledgedCloudSharing = false } } diff --git a/OSGKeyboardShared/Services/AppGroupConfigDarwin.swift b/OSGKeyboardShared/Services/AppGroupConfigDarwin.swift new file mode 100644 index 0000000..d5308b0 --- /dev/null +++ b/OSGKeyboardShared/Services/AppGroupConfigDarwin.swift @@ -0,0 +1,22 @@ +// AppGroupConfigDarwin.swift +// OSGKeyboard · Shared +// +// Cross-process Darwin notification when App Group config changes +// (translation target, cloud-polish toggle, etc.). Lets the host app +// and keyboard extension pick up writes without waiting on the 1 Hz poll. + +import Foundation + +public enum AppGroupConfigDarwin { + public static let notificationName = "com.osgkeyboard.config.changed" + + public static func postConfigChanged() { + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), + CFNotificationName(notificationName as CFString), + nil, + nil, + true + ) + } +} diff --git a/OSGKeyboardShared/Services/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift index 635a463..50fd422 100644 --- a/OSGKeyboardShared/Services/AppGroupStore.swift +++ b/OSGKeyboardShared/Services/AppGroupStore.swift @@ -45,6 +45,7 @@ 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" } // MARK: - Reads @@ -127,6 +128,11 @@ 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 + } + // MARK: - Writes public func setModeId(_ id: String) { @@ -169,6 +175,70 @@ public struct AppGroupStore: @unchecked Sendable { /// round-trip. public func setTranslationTargetLocaleId(_ id: String) { defaults.set(id, forKey: Key.translationTargetLocaleId) + AppGroupConfigDarwin.postConfigChanged() + } + + public func setPolishScenarioId(_ id: String) { + let resolved = PolishScenarioCatalog.resolve(id).id + defaults.set(resolved, forKey: Key.polishScenarioId) + AppGroupConfigDarwin.postConfigChanged() + } + + /// Whether ASR output should be sent through the cloud LLM step. + /// Cloud engine: always. Local engine: only when cloud polish is + /// enabled (translation is a sub-option of that step). + public var shouldRunCloudLLMStep: Bool { + if engineMode == "cloud" { return true } + return localModeCloudPolishEnabled + } + + /// Whether translate-and-polish should run (vs polish-only). + public var isTranslationEffective: Bool { + guard translationEnabled else { return false } + if engineMode == "local" { return localModeCloudPolishEnabled } + return true + } + + /// Whether the keyboard top-bar translation chip should render. + /// Cloud engine: always. Local engine: when cloud polish is enabled + /// (translation is a sub-option of that LLM step). Independent of + /// whether a target locale is currently selected — the chip stays + /// visible so the user can pick "不翻译" or a language in-place. + public var isTranslationChipVisible: Bool { + if engineMode == "cloud" { return true } + 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 + ? .translate(targetLocaleId: translationTargetLocaleId) + : .polish + } + + /// Local engine pins the LLM step to DeepSeek; cloud uses the + /// user's configured provider. + public var polishProviderIdOverride: String? { + engineMode == "local" ? "deepseek" : nil } // MARK: - Client diff --git a/OSGKeyboardShared/Services/FlowSessionDarwin.swift b/OSGKeyboardShared/Services/FlowSessionDarwin.swift index 8474ec5..a3ddb85 100644 --- a/OSGKeyboardShared/Services/FlowSessionDarwin.swift +++ b/OSGKeyboardShared/Services/FlowSessionDarwin.swift @@ -20,7 +20,7 @@ public enum FlowSessionDarwin { } } -/// Observes Flow session Darwin notifications on a background thread; invokes +/// Observes Darwin notifications on a background thread; invokes /// `handler` on the main actor. public final class FlowSessionDarwinObserver { private final class Box: @unchecked Sendable { @@ -30,11 +30,16 @@ public final class FlowSessionDarwinObserver { private let box: Box private let token: UnsafeMutableRawPointer + private let notificationName: CFString - public init(handler: @escaping @MainActor () -> Void) { + public init( + notificationName: String = FlowSessionDarwin.notificationName, + handler: @escaping @MainActor () -> Void + ) { let box = Box(handler: handler) self.box = box self.token = Unmanaged.passRetained(box).toOpaque() + self.notificationName = notificationName as CFString CFNotificationCenterAddObserver( CFNotificationCenterGetDarwinNotifyCenter(), @@ -44,7 +49,7 @@ public final class FlowSessionDarwinObserver { let box = Unmanaged.fromOpaque(observer).takeUnretainedValue() Task { @MainActor in box.handler() } }, - FlowSessionDarwin.notificationName as CFString, + self.notificationName, nil, .deliverImmediately ) @@ -54,7 +59,7 @@ public final class FlowSessionDarwinObserver { CFNotificationCenterRemoveObserver( CFNotificationCenterGetDarwinNotifyCenter(), token, - CFNotificationName(FlowSessionDarwin.notificationName as CFString), + CFNotificationName(notificationName), nil ) Unmanaged.fromOpaque(token).release() diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift index 6669952..ad79a53 100644 --- a/OSGKeyboardShared/Services/KeyboardState.swift +++ b/OSGKeyboardShared/Services/KeyboardState.swift @@ -98,14 +98,29 @@ 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 - /// v0.2.1: effective predicate — mirrors `ProviderConfig`. - /// v0.2.1 follow-up: no longer gates on `engineMode == "cloud"` - /// because the local engine's translate-and-polish step now runs - /// (routed through DeepSeek). Row visibility (`isTranslationRowVisible` - /// on `ProviderConfig`) keeps the picker honest, so the keyboard - /// can rely on `translationEnabled` alone here. + /// 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 + /// Whether translate-and-polish is actually armed for the current + /// engine (local requires cloud polish + a target locale). public var isTranslationEffective: Bool { - translationEnabled + guard translationEnabled else { return false } + if isLocalEngine { return localModeCloudPolishEnabled } + return true + } + + /// Whether the keyboard top-bar translation chip should render. + public var isTranslationChipVisible: Bool { + if isLocalEngine { return localModeCloudPolishEnabled } + 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. @@ -125,6 +140,7 @@ 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 } public var insertNewline: () -> Void = {} public var insertSpace: () -> Void = {} public var deleteBackward: () -> Void = {} diff --git a/OSGKeyboardShared/Services/PolishingService.swift b/OSGKeyboardShared/Services/PolishingService.swift index 9cb517f..341c19a 100644 --- a/OSGKeyboardShared/Services/PolishingService.swift +++ b/OSGKeyboardShared/Services/PolishingService.swift @@ -8,14 +8,12 @@ // Engine matrix: // - `engineMode == "cloud"` → always polish (cloud engine's whole point). // - `engineMode == "local"`, -// `localModeCloudPolishEnabled == false` → ASR-only, return raw. +// cloud polish disabled → ASR-only, return raw. // - `engineMode == "local"`, -// `localModeCloudPolishEnabled == true` → polish via the user's LLM -// (DeepSeek by default). The local engine gains stronger accuracy on -// noisy / dialectal Chinese at the cost of one cloud round-trip. -// If the user hasn't entered an API key the call falls back to the -// raw transcript and surfaces a warning so the keyboard can show -// the "fill in your key" hint. +// cloud polish enabled → DeepSeek LLM step (polish or translate). +// Translation uses `.translate` + `TranslationPrompt`; polish uses +// the default system prompt. Missing preconfigured DeepSeek key +// throws `missingAPIKey` and callers deliver raw + warning. import Foundation @@ -24,10 +22,8 @@ public actor PolishingService { public enum PolishError: Error, Equatable { case noTranscript case timeout - /// v0.2.0: local engine + cloud-polish-on, but the user hasn't - /// saved an API key in the Keychain. Caller surfaces an Alert - /// telling them to fill it in; we deliver the raw transcript - /// so no data is lost. + /// Local engine DeepSeek step: `PreconfiguredKeys.deepseek` is + /// still the repo placeholder, or cloud engine Keychain is empty. case missingAPIKey } @@ -74,16 +70,10 @@ public actor PolishingService { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw PolishError.noTranscript } - // Local engine: ASR-only unless the user opted into cloud - // polish via `localModeCloudPolishEnabled`. The cloud polish - // path still requires an API key; if the Keychain is empty we - // fall back to the raw transcript and throw `missingAPIKey` - // so the UI can surface the "fill in your key" hint. + // Local engine: ASR-only unless cloud polish is enabled + // (translation is a sub-option of that LLM step). if store.engineMode == "local" { - guard store.localModeCloudPolishEnabled else { return trimmed } - guard !store.apiKey.isEmpty else { - throw PolishError.missingAPIKey - } + guard store.shouldRunCloudLLMStep else { return trimmed } return try await polishRemote( trimmed, mode: mode, @@ -117,12 +107,11 @@ public actor PolishingService { client = injectedClient } else { let preset = LLMProvider.provider(id: effectiveProviderId) - let baseURL = store.baseURL.isEmpty ? preset.defaultBaseURL : store.baseURL - // Pre-existing typo fix: the user-overridden `store.model` - // path was returning `preset.defaultModel` on both branches, - // silently ignoring the user's custom model field. Restore - // the asymmetry so the user override actually wins. - let model = store.model.isEmpty ? preset.defaultModel : store.model + let (baseURL, model) = Self.resolveLLMEndpoint( + store: store, + preset: preset, + providerIdOverride: providerIdOverride + ) let apiKey: String if effectiveProviderId == "deepseek" { let preconfigured = PreconfiguredKeys.deepseek @@ -138,7 +127,11 @@ public actor PolishingService { } client = OpenAICompatibleClient(baseURL: baseURL, apiKey: apiKey, model: model) } - let prompt = resolvedSystemPrompt(for: mode, override: systemPrompt) + let prompt = resolvedSystemPrompt( + for: mode, + override: systemPrompt, + providerId: effectiveProviderId + ) let budget = effectiveTimeout(for: trimmed) return try await withThrowingTaskGroup(of: String.self) { group in @@ -161,17 +154,26 @@ public actor PolishingService { /// existing `store.systemPrompt` behaviour so every other call site /// is byte-identical to before. An explicit `override` wins over /// both paths so callers (and tests) can pin a specific prompt. - private func resolvedSystemPrompt(for mode: PolishMode, override: String? = nil) -> String { + private func resolvedSystemPrompt( + for mode: PolishMode, + override: String? = nil, + providerId: String? = nil + ) -> String { if let override, !override.isEmpty { return override } switch mode { case .polish: - return store.systemPrompt + return store.resolvedPolishSystemPrompt(providerId: providerId) case .translate(let targetLocaleId): let target = TranslationLanguageCatalog.resolve(targetLocaleId) - let pid = store.providerId - return TranslationPrompt.make(target: target, providerId: pid) + let pid = providerId ?? store.providerId + return TranslationPrompt.make( + target: target, + providerId: pid, + scenarioId: store.polishScenarioId, + uiLanguage: store.uiLanguage + ) } } @@ -180,4 +182,41 @@ public actor PolishingService { let scaled = timeout + (Double(text.count) / 200.0) * 2.0 return min(max(scaled, timeout), 120) } + + /// Picks base URL + model for one remote polish call. + /// + /// When `providerIdOverride` is set (local engine pins DeepSeek), + /// always use that preset's defaults so cloud-engine settings + /// (e.g. Qwen base URL saved while testing cloud mode) are not + /// mixed with the pinned provider's API key. Cloud engine passes + /// `nil` and keeps honoring `store.baseURL` / `store.model`. + internal static func resolveLLMEndpoint( + store: AppGroupStore, + preset: LLMProvider, + providerIdOverride: String? + ) -> (baseURL: String, model: String) { + if providerIdOverride != nil { + return (preset.defaultBaseURL, preset.defaultModel) + } + let baseURL = store.baseURL.isEmpty ? preset.defaultBaseURL : store.baseURL + // Pre-existing typo fix: the user-overridden `store.model` + // path was returning `preset.defaultModel` on both branches, + // silently ignoring the user's custom model field. Restore + // the asymmetry so the user override actually wins. + let model = store.model.isEmpty ? preset.defaultModel : store.model + return (baseURL, model) + } +} + +extension PolishingService.PolishError: LocalizedError { + public var errorDescription: String? { + switch self { + case .noTranscript: + return "No transcript to polish." + case .timeout: + return "LLM polish timed out." + case .missingAPIKey: + return "Missing API key (local: set PreconfiguredKeys.deepseek; cloud: Settings API key)." + } + } } diff --git a/OSGKeyboardShared/Services/PreconfiguredKeys.swift b/OSGKeyboardShared/Services/PreconfiguredKeys.swift index 9656033..f6a6e53 100644 --- a/OSGKeyboardShared/Services/PreconfiguredKeys.swift +++ b/OSGKeyboardShared/Services/PreconfiguredKeys.swift @@ -25,7 +25,7 @@ public enum PreconfiguredKeys { /// Preconfigured DeepSeek API key. Replace `placeholder` with a /// real key in `Sources/.../PreconfiguredKeys.swift` before /// distributing a build. - public static let deepseek: String = placeholder + public static let deepseek: String = "REMOVED_LEAKED_DEEPSEEK_KEY" #if DEBUG /// Forces a lazy init at app launch in DEBUG builds so the assert @@ -47,4 +47,4 @@ public enum PreconfiguredKeys { _ = isDeepseekConfigured } #endif -} \ No newline at end of file +} diff --git a/OSGKeyboardShared/Services/ScenarioPrompt.swift b/OSGKeyboardShared/Services/ScenarioPrompt.swift new file mode 100644 index 0000000..7ea9b80 --- /dev/null +++ b/OSGKeyboardShared/Services/ScenarioPrompt.swift @@ -0,0 +1,59 @@ +// 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. + """ +} diff --git a/OSGKeyboardShared/Services/ScenarioStyleDirective.swift b/OSGKeyboardShared/Services/ScenarioStyleDirective.swift new file mode 100644 index 0000000..a53fc12 --- /dev/null +++ b/OSGKeyboardShared/Services/ScenarioStyleDirective.swift @@ -0,0 +1,171 @@ +// 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. + """ + } + } +} diff --git a/OSGKeyboardShared/Services/TranslationPrompt.swift b/OSGKeyboardShared/Services/TranslationPrompt.swift index d43aa41..c42cc20 100644 --- a/OSGKeyboardShared/Services/TranslationPrompt.swift +++ b/OSGKeyboardShared/Services/TranslationPrompt.swift @@ -12,9 +12,8 @@ // 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. -// Keeping those two concerns in one prompt matches how our existing -// polish prompt already mixes "preserve meaning" with "fix punctuation / -// drop filler". +// Scenario output format (`ScenarioStyleDirective`) is appended so +// translate-and-polish honours the user's polish scenario choice. import Foundation @@ -26,36 +25,53 @@ public enum TranslationPrompt { /// - target: target language entry resolved via `TranslationLanguageCatalog`. /// - providerId: provider preset id (e.g. `"deepseek"`, `"openai"`); /// drives the language the prompt is written in. - public static func make(target: TranslationLanguage, providerId: String) -> String { + /// - 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 + ) -> String { let isChineseNative = ["zhipu", "moonshot", "qwen", "deepseek"].contains(providerId) - return isChineseNative ? chinesePrompt(target: target) : englishPrompt(target: target) + let directive = ScenarioStyleDirective.make( + scenarioId: scenarioId, + providerId: providerId, + uiLanguage: uiLanguage + ) + return isChineseNative + ? chinesePrompt(target: target, directive: directive) + : englishPrompt(target: target, directive: directive) } // MARK: - Chinese prompt (for DeepSeek / Qwen / GLM / Moonshot) - private static func chinesePrompt(target: TranslationLanguage) -> String { + private static func chinesePrompt(target: TranslationLanguage, directive: String) -> String { """ 你是一位语音输入翻译与润色助手。用户用 ASR 转写了一段可能含噪声的口述: 1) 先识别原话的主要语言(若不确定则按用户给定的方向处理); 2) 将内容翻译为「\(target.promptLanguageName)」,保留原意,不增删事实、不臆测; 3) 顺带修复 ASR 噪声(同音错字、漏字、断句错乱),让译文读起来自然; - 4) 保留枚举结构(第一…第二…),使用「\(target.promptLanguageName)」的列表惯例; - 5) 简洁,不超过原文 1.5 倍;去掉无意义的口头禅(嗯、啊、那个); + 4) 简洁;若下方场景未要求列表/分段,不超过原文 1.5 倍;去掉无意义的口头禅(嗯、啊、那个); + 5) 若场景格式要求列表或分段,允许按格式组织译文;总长度不超过原长 2 倍; 6) 只输出译文正文,不要解释、不要加引号、不要前缀"以下是翻译"。 + \(directive) """ } // MARK: - English prompt (for OpenAI / OpenAI-compatible non-Chinese) - private static func englishPrompt(target: TranslationLanguage) -> String { + private static func englishPrompt(target: TranslationLanguage, directive: 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) Preserve enumeration ("first ... second ...") using \(target.promptLanguageName) list conventions; - 5) Keep it concise — no longer than 1.5x the spoken length; 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; 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) """ } -} \ No newline at end of file +} diff --git a/OSGKeyboardShared/en.lproj/Shared.strings b/OSGKeyboardShared/en.lproj/Shared.strings index df42de5..83c252f 100644 --- a/OSGKeyboardShared/en.lproj/Shared.strings +++ b/OSGKeyboardShared/en.lproj/Shared.strings @@ -5,7 +5,7 @@ "engine.asr.appleSpeech" = "Apple SpeechAnalyzer"; /* v0.2.0: flow-level warnings surfaced alongside the final transcript. */ -"flow.warning.cloudPolishMissingKey" = "Cloud polish is on, but no API key is set. Inserted the raw ASR transcript — fill in your DeepSeek key in Settings to enable polish."; +"flow.warning.cloudPolishMissingKey" = "Cloud polish/translation needs a DeepSeek API key. Inserted raw ASR text — set PreconfiguredKeys.deepseek in the project (local engine) or API key in Settings (cloud engine)."; /* LLM providers */ "provider.openai" = "OpenAI"; @@ -30,3 +30,21 @@ "error.asr.formatUnsupported" = "This device does not support the required audio format."; "error.asr.noSpeech" = "No speech detected. Please try again."; "error.asr.chunkFailed" = "Segment %lld failed: %@"; + +/* Polish scenarios */ +"polishScenario.daily_chat" = "Daily Chat"; +"polishScenario.social_lifestyle" = "Social Network"; +"polishScenario.social_short" = "Instagram"; +"polishScenario.goofy" = "Goofy"; +"polishScenario.work" = "Work"; +"polishScenario.document" = "Document"; +"polishScenario.todo" = "TODO"; +"polishScenario.custom" = "Custom"; +"polishScenario.chip.daily_chat" = "Chat"; +"polishScenario.chip.social_lifestyle" = "Social"; +"polishScenario.chip.social_short" = "IG"; +"polishScenario.chip.goofy" = "Goofy"; +"polishScenario.chip.work" = "Work"; +"polishScenario.chip.document" = "Doc"; +"polishScenario.chip.todo" = "TODO"; +"polishScenario.chip.custom" = "Custom"; diff --git a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings index a3f806b..883801d 100644 --- a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings +++ b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings @@ -5,7 +5,7 @@ "engine.asr.appleSpeech" = "Apple 语音识别"; /* v0.2.0: flow-level warnings surfaced alongside the final transcript. */ -"flow.warning.cloudPolishMissingKey" = "已开启云端润色但未填写 API Key,本次以原始识别结果插入。请在设置中填入 DeepSeek API Key 以启用润色。"; +"flow.warning.cloudPolishMissingKey" = "云端润色/翻译需要 DeepSeek API Key,本次已插入原始识别结果。本地引擎请在 PreconfiguredKeys.swift 配置;云端引擎请在设置中填写 API Key。"; /* LLM providers */ "provider.openai" = "OpenAI"; @@ -30,3 +30,21 @@ "error.asr.formatUnsupported" = "当前设备不支持该语音输入格式。"; "error.asr.noSpeech" = "未识别到语音内容,请重试。"; "error.asr.chunkFailed" = "第 %lld 段识别失败:%@"; + +/* 润色场景 */ +"polishScenario.daily_chat" = "日常聊天"; +"polishScenario.social_lifestyle" = "小红书"; +"polishScenario.social_short" = "微博"; +"polishScenario.goofy" = "逗比"; +"polishScenario.work" = "工作"; +"polishScenario.document" = "文档写作"; +"polishScenario.todo" = "TODO 记录"; +"polishScenario.custom" = "自定义"; +"polishScenario.chip.daily_chat" = "聊天"; +"polishScenario.chip.social_lifestyle" = "小红书"; +"polishScenario.chip.social_short" = "微博"; +"polishScenario.chip.goofy" = "逗比"; +"polishScenario.chip.work" = "工作"; +"polishScenario.chip.document" = "文档"; +"polishScenario.chip.todo" = "TODO"; +"polishScenario.chip.custom" = "自定义"; diff --git a/OSGKeyboardTests/LLMClientTests.swift b/OSGKeyboardTests/LLMClientTests.swift index 65cd5a2..79c1a80 100644 --- a/OSGKeyboardTests/LLMClientTests.swift +++ b/OSGKeyboardTests/LLMClientTests.swift @@ -341,6 +341,196 @@ final class LLMClientTests: XCTestCase { let calls = await counter.value() XCTAssertEqual(calls, 0) } + + /// Local engine pins DeepSeek — cloud-provider URL/model in App Group + /// must not leak into the LLM request (regression: Qwen URL + DeepSeek key → 401). + func testResolveLLMEndpointUsesPresetWhenProviderPinned() { + 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("qwen", forKey: "config.providerId") + defaults.set( + "https://dashscope.aliyuncs.com/compatible-mode/v1", + forKey: "config.baseURL" + ) + defaults.set("qwen-plus", forKey: "config.model") + + let store = AppGroupStore(defaults: defaults) + let deepseekPreset = LLMProvider.provider(id: "deepseek") + let pinned = PolishingService.resolveLLMEndpoint( + store: store, + preset: deepseekPreset, + providerIdOverride: "deepseek" + ) + XCTAssertEqual(pinned.baseURL, deepseekPreset.defaultBaseURL) + XCTAssertEqual(pinned.model, deepseekPreset.defaultModel) + + let qwenPreset = LLMProvider.provider(id: "qwen") + let cloud = PolishingService.resolveLLMEndpoint( + store: store, + preset: qwenPreset, + providerIdOverride: nil + ) + XCTAssertEqual( + cloud.baseURL, + "https://dashscope.aliyuncs.com/compatible-mode/v1", + "cloud engine must keep user base URL" + ) + XCTAssertEqual(cloud.model, "qwen-plus", "cloud engine must keep user model") + } + + func testTranslationChipVisibleWithoutTargetLocale() { + 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("cloud", forKey: "config.engineMode") + defaults.set(TranslationLanguageCatalog.offLocaleId, forKey: "config.translationTargetLocaleId") + let cloudStore = AppGroupStore(defaults: defaults) + XCTAssertTrue(cloudStore.isTranslationChipVisible) + XCTAssertFalse(cloudStore.isTranslationEffective) + + defaults.set("local", forKey: "config.engineMode") + defaults.set(true, forKey: "config.localModeCloudPolishEnabled") + let localPolishOn = AppGroupStore(defaults: defaults) + XCTAssertTrue(localPolishOn.isTranslationChipVisible) + XCTAssertFalse(localPolishOn.isTranslationEffective) + + defaults.set(false, forKey: "config.localModeCloudPolishEnabled") + let localPolishOff = AppGroupStore(defaults: defaults) + XCTAssertFalse(localPolishOff.isTranslationChipVisible) + } + + /// Local engine with translation enabled must invoke the LLM even + /// when the cloud-polish toggle is off. + func testPolisherSkipsLLMWhenLocalCloudPolishOffEvenWithTranslation() async throws { + 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("en", forKey: "config.translationTargetLocaleId") + defaults.set(false, forKey: "config.localModeCloudPolishEnabled") + + let counter = CallCounter() + let countingClient = CountingLLMClient(counter: counter) { _, _ in + XCTFail("cloud LLMClient must not run when local cloud polish is off") + return "" + } + + let store = AppGroupStore(defaults: defaults) + XCTAssertFalse(store.shouldRunCloudLLMStep) + + let polisher = PolishingService( + store: store, + client: countingClient, + timeout: 1 + ) + + let result = try await polisher.polish( + " 你好 ", + mode: .translate(targetLocaleId: "en"), + providerIdOverride: "deepseek" + ) + XCTAssertEqual(result, "你好") + let calls = await counter.value() + XCTAssertEqual(calls, 0) + } + + /// Local engine with cloud polish + translation enabled invokes LLM. + func testPolisherTranslatesWhenLocalEngineTranslationEnabled() async throws { + 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("en", forKey: "config.translationTargetLocaleId") + defaults.set(true, forKey: "config.localModeCloudPolishEnabled") + + let counter = CallCounter() + let countingClient = CountingLLMClient(counter: counter) { raw, prompt in + XCTAssertEqual(raw, "你好") + XCTAssertTrue(prompt.contains("English"), "translate prompt should target English") + return "Hello" + } + + let store = AppGroupStore(defaults: defaults) + let polisher = PolishingService( + store: store, + client: countingClient, + timeout: 1 + ) + + let result = try await polisher.polish( + " 你好 ", + mode: .translate(targetLocaleId: "en"), + providerIdOverride: "deepseek" + ) + XCTAssertEqual(result, "Hello") + let calls = await counter.value() + 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() { + let prompt = TranslationPrompt.make( + target: TranslationLanguageCatalog.resolve("en"), + providerId: "openai", + scenarioId: "work" + ) + 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")) + } } // MARK: - Test helpers diff --git a/project.yml b/project.yml index f556914..11b3547 100644 --- a/project.yml +++ b/project.yml @@ -39,8 +39,8 @@ settings: GENERATE_INFOPLIST_FILE: NO ENABLE_MODULE_VERIFIER: YES CLANG_CXX_LANGUAGE_STANDARD: c++17 - MARKETING_VERSION: "0.2.0" - CURRENT_PROJECT_VERSION: "4" + MARKETING_VERSION: "0.3.0" + CURRENT_PROJECT_VERSION: "5" # 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖) # 项目级签名 xcconfig,适用于所有 target