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..9c7c95f 100644 --- a/OSGKeyboard/Services/FlowSessionManager.swift +++ b/OSGKeyboard/Services/FlowSessionManager.swift @@ -140,6 +140,7 @@ final class FlowSessionManager: ObservableObject { FlowSessionBridge.writeHeartbeat() FlowSessionDarwin.postSessionChanged() isActive = true + ScreenWakeLock.acquire() if let expires = FlowSessionBridge.sessionExpiresAt() { sessionExpiresAt = Date(timeIntervalSince1970: expires) } @@ -187,6 +188,7 @@ final class FlowSessionManager: ObservableObject { capture.stop() endBackgroundKeepAlive() + ScreenWakeLock.release() sessionASR = nil FlowSessionBridge.markSessionInactive() FlowSessionDarwin.postSessionChanged() @@ -321,6 +323,7 @@ final class FlowSessionManager: ObservableObject { FlowSessionBridge.markSessionActive(duration: duration) FlowSessionDarwin.postSessionChanged() isActive = true + ScreenWakeLock.acquire() sessionExpiresAt = Date().addingTimeInterval(duration) startHeartbeat() @@ -560,11 +563,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 +584,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 +625,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/Utilities/ScreenWakeLock.swift b/OSGKeyboard/Utilities/ScreenWakeLock.swift new file mode 100644 index 0000000..4be52fb --- /dev/null +++ b/OSGKeyboard/Utilities/ScreenWakeLock.swift @@ -0,0 +1,26 @@ +// ScreenWakeLock.swift +// OSGKeyboard · Main App +// +// Reference-counted idle-timer disable for Flow session ownership. + +import UIKit + +@MainActor +enum ScreenWakeLock { + private static var holdCount = 0 + + static func acquire() { + holdCount += 1 + if holdCount == 1 { + UIApplication.shared.isIdleTimerDisabled = true + } + } + + static func release() { + guard holdCount > 0 else { return } + holdCount -= 1 + if holdCount == 0 { + UIApplication.shared.isIdleTimerDisabled = false + } + } +} 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/LocalEngineSettingsRows.swift b/OSGKeyboard/Views/LocalEngineSettingsRows.swift index 6de8120..946b808 100644 --- a/OSGKeyboard/Views/LocalEngineSettingsRows.swift +++ b/OSGKeyboard/Views/LocalEngineSettingsRows.swift @@ -27,11 +27,26 @@ struct LocalModelsGroup: View { @ObservedObject var config: ProviderConfig var body: some View { + // v0.2.1 follow-up: the LocalEngineGroup now owns the + // translation row so the local-engine Settings tab reads as + // one cohesive card. The same surface chrome + // (`palette.surface` + rounded border) the cloud branch uses + // on its own card wraps the whole group so it sits flush with + // the language tab above. VStack(spacing: 0) { speechRow Divider().background(palette.divider) cloudPolishRow + if config.isTranslationRowVisible { + Divider().background(palette.divider) + TranslationPickerRow(config: config, isVisible: true) + } } + .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) } // MARK: Speech row @@ -57,25 +72,21 @@ struct LocalModelsGroup: View { /// itself is always live (the user can flip it without having a /// key yet), but the polish call short-circuits with an Alert if /// the Keychain is empty when it fires. + /// + /// v0.2.1 follow-up: dropped the inline "uses DeepSeek" caption + /// (the user already opted into cloud mode by switching engines, + /// and the vendor name surfaces when they tap the row's helper + /// text in onboarding / deep links). Title + switch is enough. private var cloudPolishRow: some View { - VStack(alignment: .leading, spacing: Spacing.xs) { - Toggle(isOn: $config.localModeCloudPolishEnabled) { - VStack(alignment: .leading, spacing: 2) { - Text("settings.localModels.cloudPolish.title") - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) - Text("settings.localModels.cloudPolish.subtitle") - .font(TypeStyle.caption2) - .foregroundStyle(palette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - } - } - .toggleStyle(.switch) - .tint(palette.accent) + Toggle(isOn: $config.localModeCloudPolishEnabled) { + Text("settings.localModels.cloudPolish.title") + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) } + .toggleStyle(.switch) + .tint(palette.accent) .padding(.horizontal, Spacing.md) - .padding(.vertical, Spacing.sm) - .frame(minHeight: SettingsListMetrics.doubleLineMinHeight) + .frame(minHeight: SettingsListMetrics.singleLineMinHeight) } // MARK: Helpers diff --git a/OSGKeyboard/Views/OnboardingView.swift b/OSGKeyboard/Views/OnboardingView.swift index 4352e65..c7a056a 100644 --- a/OSGKeyboard/Views/OnboardingView.swift +++ b/OSGKeyboard/Views/OnboardingView.swift @@ -756,8 +756,36 @@ private struct APISetupPage: View { } .padding(.horizontal, Spacing.lg) } + + if config.isPolishScenarioRowVisible { + postProcessingSection + .padding(.horizontal, Spacing.lg) + } } .padding(.bottom, Spacing.xxxl) } } + + /// Polish scenario + optional translation target for cloud onboarding. + private var postProcessingSection: some View { + VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { + Text("settings.polishScenario.section") + .font(TypeStyle.caption2) + .foregroundStyle(palette.textSecondary) + .textCase(.uppercase) + .frame(maxWidth: .infinity, alignment: .leading) + VStack(spacing: 0) { + ScenarioPickerRow(config: config, isVisible: true) + if config.isTranslationRowVisible { + Divider().background(palette.divider) + TranslationPickerRow(config: config, isVisible: true) + } + } + .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) + } + } } diff --git a/OSGKeyboard/Views/ProviderPickerSection.swift b/OSGKeyboard/Views/ProviderPickerSection.swift index 4a0be00..fbbff9a 100644 --- a/OSGKeyboard/Views/ProviderPickerSection.swift +++ b/OSGKeyboard/Views/ProviderPickerSection.swift @@ -10,15 +10,20 @@ struct ProviderPickerSection: View { @ObservedObject var config: ProviderConfig var body: some View { + // v0.2.1 follow-up: filter out presets marked as + // `isUserSelectable == false` so a future "DeepSeek key + // pre-fill" preset (or similar) can ship in `presets` without + // showing up in the picker. + let visiblePresets = LLMProvider.presets.filter { $0.isUserSelectable } VStack(spacing: 0) { - ForEach(Array(LLMProvider.presets.enumerated()), id: \.element.id) { index, provider in + ForEach(Array(visiblePresets.enumerated()), id: \.element.id) { index, provider in Button { select(provider) } label: { row(provider, selected: provider.id == config.providerId) } .buttonStyle(.plain) - if index < LLMProvider.presets.count - 1 { + if index < visiblePresets.count - 1 { Divider().background(palette.divider) } } 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 e28aa54..947161e 100644 --- a/OSGKeyboard/Views/SettingsView.swift +++ b/OSGKeyboard/Views/SettingsView.swift @@ -62,23 +62,23 @@ 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 + // contractually ASR-only, so provider/model/ + // base URL/API key controls have no use — + // and exposing them invites the user to fill + // out a DeepSeek key they can't use. if config.engineMode == "cloud" { providerSection apiSection - } else if config.localModeCloudPolishEnabled { - // v0.2.0: local engine + cloud polish on. - // Surface the provider / API key fields so - // the user can fill in their DeepSeek key. - // We hide them when the toggle is off so the - // local engine stays genuinely local. - providerSection - apiSection } - languageAndModelsSection - if config.engineMode == "cloud" { - systemPromptLinkSection + if config.engineMode == "local" { + localEngineSettingsSection } if presentation == .tab { + preferencesSection footerLinks } } @@ -122,16 +122,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) { - if config.engineMode == "local" { - LocalModelsGroup(config: config) - Divider().background(palette.divider) - } LocalePickerRow( locales: effectiveLocales, selection: Binding( @@ -139,6 +135,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( @@ -169,6 +182,19 @@ struct SettingsView: View { } } + /// v0.2.1 follow-up: dedicated section for the local engine's + /// settings (cloud-polish toggle + translation row). Renders only + /// when `engineMode == "local"` so the cloud-engine user doesn't + /// see rows that are inert for them. The translation row lives + /// inside `LocalModelsGroup` so it shares the group's surface card + /// chrome — see `LocalEngineSettingsRows.swift` for the layout. + private var localEngineSettingsSection: some View { + VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { + sectionHeader("settings.localEngine.title") + LocalModelsGroup(config: config) + } + } + private var providerSection: some View { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { sectionHeader("settings.provider.title") @@ -228,22 +254,22 @@ struct SettingsView: View { dynamicLocales = entries } - // MARK: - System prompt (cloud only) + // MARK: - Preferences (tab settings only) - private var systemPromptLinkSection: some View { + private var preferencesSection: some View { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { - sectionHeader("settings.systemPrompt.title") + sectionHeader("settings.preferences.title") VStack(spacing: 0) { - NavigationLink { - SystemPromptSettingsView(config: config) - } label: { - footerNavigationRow(title: "settings.systemPrompt.edit") - } - .buttonStyle(.plain) + HandednessPickerRow( + selection: Binding( + get: { config.handednessPreference }, + set: { config.handednessPreference = $0 } + ) + ) } - .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) .stroke(palette.divider, lineWidth: 0.5) ) } @@ -362,6 +388,31 @@ struct SettingsView: View { } } +// MARK: - Handedness picker row + +private struct HandednessPickerRow: View { + @Binding var selection: HandednessPreference + + private var options: [(id: String, label: String)] { + HandednessPreference.allCases.map { preference in + (preference.rawValue, AppL10n.string(preference.labelKey)) + } + } + + var body: some View { + PickerRow( + title: AppL10n.string("settings.handedness.title"), + options: options, + selection: Binding( + get: { selection.rawValue }, + set: { newValue in + selection = HandednessPreference(rawValue: newValue) ?? .left + } + ) + ) + } +} + // MARK: - Picker row (generic) private struct PickerRow: 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/Views/TranslationPickerRow.swift b/OSGKeyboard/Views/TranslationPickerRow.swift new file mode 100644 index 0000000..f37abcc --- /dev/null +++ b/OSGKeyboard/Views/TranslationPickerRow.swift @@ -0,0 +1,108 @@ +// TranslationPickerRow.swift +// OSGKeyboard · Main App +// +// Single-row "翻译" picker — replaces the previous two-row toggle + +// target-locale dropdown. Lets the user pick "不翻译" (off, the +// default) or one of the 10 target languages, all from a single +// `Menu`. +// +// v0.2.1 follow-up: row is rendered through an `isVisible` parameter +// so callers (`SettingsView`, `OnboardingView`) can drop the row +// entirely when the engine can't run the cloud translate-and-polish +// step (`ProviderConfig.isTranslationRowVisible`). The "needs cloud" +// inline hint was deleted along with the previous Bool toggle — the +// user only sees the row when the engine can act on the choice. +// +// v0.2.1 final review: both engines now run the translate-and-polish +// step (the local engine routes through DeepSeek via +// `ProviderConfig.localModeProviderId`), so the row title changed +// from "Translation" to "Polish then translate" to match the new +// always-on translation contract. +// +// Mapping to persisted state: +// • "不翻译" → translationTargetLocaleId = "off" +// • any specific locale → translationTargetLocaleId = +// +// The pipeline (`PolishingService`) honors `.translate` on both +// engines when this row is visible — no more "rejected mode" toast. + +import SwiftUI +import OSGKeyboardShared + +struct TranslationPickerRow: View { + @Environment(\.themePalette) private var palette: ThemePalette + @ObservedObject var config: ProviderConfig + + /// Visibility flag — when `false` the row renders as `EmptyView` + /// (callers can also wrap the call site in `if` for symmetry, but + /// having the guard here means a forgotten `if` still produces a + /// safe no-op rather than a leaked dead row). + var isVisible: Bool = true + + var body: some View { + if isVisible { + HStack { + Text("settings.translation.afterPolish") + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + Spacer() + Menu { + ForEach(TranslationLanguageCatalog.all) { language in + Button { + apply(language) + } label: { + if currentSelectionId == language.id { + Label(displayLabel(for: language), systemImage: "checkmark") + } else { + Text(displayLabel(for: language)) + } + } + } + } label: { + HStack(spacing: 6) { + Text(currentLabel) + .font(TypeStyle.body) + .foregroundStyle(currentIsOff ? palette.textSecondary : 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) + } + } + + // MARK: - Selection plumbing + + /// Currently selected id — the picker always reads + /// `translationTargetLocaleId` directly (the previous + /// `translationEnabled` boolean is now derived from it). + private var currentSelectionId: String { + config.translationTargetLocaleId + } + + private var currentLabel: String { + displayLabel(for: TranslationLanguageCatalog.resolve(currentSelectionId)) + } + + private var currentIsOff: Bool { + TranslationLanguageCatalog.isOff(currentSelectionId) + } + + private func displayLabel(for language: TranslationLanguage) -> String { + if language.id == TranslationLanguageCatalog.offLocaleId { + return AppL10n.string("settings.translation.off") + } + return language.nativeName + } + + /// Translates a picker choice into a single persisted field. + /// "不翻译" writes `offLocaleId`; any concrete locale writes its + /// id. `ProviderConfig.translationEnabled` is derived from the + /// resulting value, so callers don't need to flip a separate Bool. + private func apply(_ language: TranslationLanguage) { + config.translationTargetLocaleId = language.id + } +} \ No newline at end of file diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index 2a8bd74..816a638 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -108,6 +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"; @@ -116,6 +124,7 @@ "settings.localModels.readiness %lld %lld" = "%lld/%lld ready"; "settings.localModels.cloudPolish.title" = "Cloud polish after ASR"; "settings.localModels.cloudPolish.subtitle" = "Sends the transcript to your configured cloud LLM (DeepSeek by default) for cleanup. Enable only when iOS speech recognition struggles — noisy far-field audio, strong accents, etc. Requires a DeepSeek API key."; +"settings.localEngine.title" = "Local engine"; "settings.language.subtitle.cloud" = "Recognition language and text processing mode."; "settings.language.subtitle.local" = "Recognition language."; "settings.mode.title" = "Mode"; @@ -126,6 +135,10 @@ "settings.systemPrompt.edit" = "Edit system prompt"; "settings.systemPrompt.hint" = "Used by the cloud LLM when polishing your dictation. Local engine skips this step."; "settings.about.title" = "About"; +"settings.preferences.title" = "Preferences"; +"settings.handedness.title" = "Handedness"; +"settings.handedness.left" = "Left hand"; +"settings.handedness.right" = "Right hand"; "settings.systemPrompt.reset" = "Reset"; "settings.asrLocale" = "ASR locale"; "settings.engineBadge.ios26" = "SpeechAnalyzer — always on-device"; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index e1accb3..5d1f2df 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,6 +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" = "语音识别"; @@ -116,6 +124,7 @@ "settings.localModels.readiness %lld %lld" = "%lld/%lld 已就绪"; "settings.localModels.cloudPolish.title" = "识别后云端润色"; "settings.localModels.cloudPolish.subtitle" = "将识别文本发送给已配置的云端大模型(默认 DeepSeek)进行润色。仅在 iOS 语音识别效果不理想时(远场、噪声、方言)开启,需提前在设置中填入 DeepSeek API Key。"; +"settings.localEngine.title" = "本地引擎"; "settings.language.subtitle.cloud" = "选择识别语言和文字处理模式。"; "settings.language.subtitle.local" = "选择识别语言。"; "settings.mode.title" = "模式"; @@ -126,6 +135,10 @@ "settings.systemPrompt.edit" = "编辑系统提示"; "settings.systemPrompt.hint" = "云端润色时使用。本地识别模式不会用到此提示词。"; "settings.about.title" = "关于"; +"settings.preferences.title" = "偏好设置"; +"settings.handedness.title" = "握持偏好"; +"settings.handedness.left" = "左手"; +"settings.handedness.right" = "右手"; "settings.systemPrompt.reset" = "重置"; "settings.asrLocale" = "识别语言"; "settings.engineBadge.ios26" = "SpeechAnalyzer — 始终端侧"; diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index 099a623..ddbb7f8 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() } @@ -103,11 +115,13 @@ public final class KeyboardViewController: UIInputViewController { if isPendingFlowStart || isFlowRecording || isAwaitingFlowResult || awaitingDictationResult { return } + ExtensionScreenWakeLock.releaseAll() cancelPipeline() } public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + configureDictationBehavior() KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess) consumePendingDictationResultIfNeeded() refreshDictationProgressStateIfNeeded() @@ -115,6 +129,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 +151,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() { @@ -138,16 +171,52 @@ public final class KeyboardViewController: UIInputViewController { state.setLocale = { [weak self] l in self?.persistLocale(l) } state.setEngineMode = { [weak self] m in self?.persistEngineMode(m) } state.setLocalASRBackend = { [weak self] b in self?.persistLocalASRBackend(b) } + // 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([ @@ -155,11 +224,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 @@ -200,8 +264,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() @@ -313,6 +397,7 @@ public final class KeyboardViewController: UIInputViewController { isFlowRecording = false stopUtteranceCountdown() + ExtensionScreenWakeLock.release() FlowSessionBridge.setRecordingState(.stopped) state.phase = .processing state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing") @@ -329,6 +414,7 @@ public final class KeyboardViewController: UIInputViewController { isFlowRecording = true state.lastTranscript = "" state.phase = .recording + ExtensionScreenWakeLock.acquire(from: view) startUtteranceCountdown() startFlowLevelWatchdog() debug("startFlowRecording") @@ -491,6 +577,7 @@ public final class KeyboardViewController: UIInputViewController { if isFlowRecording || isPendingFlowStart { if isFlowRecording { FlowSessionBridge.setRecordingState(.aborted) + ExtensionScreenWakeLock.release() } isFlowRecording = false isPendingFlowStart = false @@ -512,8 +599,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 { @@ -524,12 +612,19 @@ 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 } + let polishMode = runtimeStore.polishModeForPipeline + let overrideProviderId = runtimeStore.polishProviderIdOverride do { - let polished = try await self.polisher.polish(trimmed) + let polished = try await self.polisher.polish( + trimmed, + mode: polishMode, + providerIdOverride: overrideProviderId + ) self.textDocumentProxy.insertText(polished) self.state.lastTranscript = "" self.state.phase = .idle @@ -620,6 +715,28 @@ public final class KeyboardViewController: UIInputViewController { persistor.persist(localASRBackend: backend) } + // MARK: - Translation persistence + + /// v0.2.1 follow-up: persist translation target locale id. Resolved + /// via `TranslationLanguageCatalog.resolve` so a stale persisted + /// value (e.g. a removed locale id from an older build) still finds + /// the right entry instead of crashing the picker. Translation's + /// "on/off" state is now derived from this id (== `offLocaleId` + /// means off), so there's no separate toggle to persist. + 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 fb99354..5021bcf 100644 --- a/OSGKeyboardExt/Services/AppGroupPersistor.swift +++ b/OSGKeyboardExt/Services/AppGroupPersistor.swift @@ -35,6 +35,14 @@ public struct AppGroupPersistor { state.mode = .polish state.engineMode = store.engineMode state.localASRBackend = store.localASRBackend + // v0.2.1 follow-up: only the target locale is persisted — + // `translationEnabled` is derived from it. Hydrate once at + // startup; `refreshRuntimeFlags` keeps the chip in sync while + // the keyboard stays open. + state.translationTargetLocaleId = store.translationTargetLocaleId + state.polishScenarioId = store.polishScenarioId + state.handednessPreference = store.handednessPreference + state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled // v0.2.0: iOS `SpeechAnalyzer` is always ready; mirror that // into the State flags so downstream consumers see the same // shape they did when the previous Qwen3 stack reported "ready". @@ -70,11 +78,29 @@ 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 + 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 + } + state.handednessPreference = store.handednessPreference // v0.2.0: iOS `SpeechAnalyzer` is always ready. Keep these // toggles here so the keyboard UI doesn't flicker if the host // app briefly clears them while refactoring. @@ -105,4 +131,23 @@ public struct AppGroupPersistor { guard AppGroup.isAvailable else { return } AppGroupStore().setLocalASRBackend(localASRBackend) } + + /// v0.2.1: persist translation target locale id (e.g. `"en"`, + /// `"ja"`, or `TranslationLanguageCatalog.offLocaleId`). The + /// chip / picker call this through `KeyboardState.setTranslationTargetLocaleId`. + /// + /// v0.2.1 follow-up: removed `persist(translationEnabled:)` — the + /// enabled state is derived from the locale id, so callers only + /// need to write the locale. Keeping the legacy Bool overload + /// around would have implied that there's a separate on/off + /// switch to persist, which is no longer the model. + public func persist(translationTargetLocaleId: String) { + 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/Utilities/ExtensionScreenWakeLock.swift b/OSGKeyboardExt/Utilities/ExtensionScreenWakeLock.swift new file mode 100644 index 0000000..0cfe779 --- /dev/null +++ b/OSGKeyboardExt/Utilities/ExtensionScreenWakeLock.swift @@ -0,0 +1,45 @@ +// ExtensionScreenWakeLock.swift +// OSGKeyboard · Keyboard Extension +// +// Keyboard extensions cannot call `UIApplication.shared`; walk the +// responder chain to reach the host app's `UIApplication` instead. + +import UIKit + +@MainActor +enum ExtensionScreenWakeLock { + private static var holdCount = 0 + private static weak var capturedApplication: UIApplication? + + static func acquire(from responder: UIResponder) { + holdCount += 1 + if holdCount == 1 { + capturedApplication = findApplication(from: responder) + capturedApplication?.isIdleTimerDisabled = true + } + } + + static func release() { + guard holdCount > 0 else { return } + holdCount -= 1 + if holdCount == 0 { + capturedApplication?.isIdleTimerDisabled = false + capturedApplication = nil + } + } + + static func releaseAll() { + holdCount = 0 + capturedApplication?.isIdleTimerDisabled = false + capturedApplication = nil + } + + private static func findApplication(from responder: UIResponder) -> UIApplication? { + var current: UIResponder? = responder + while let node = current { + if let application = node as? UIApplication { return application } + current = node.next + } + return nil + } +} diff --git a/OSGKeyboardExt/Views/KeyboardRootView.swift b/OSGKeyboardExt/Views/KeyboardRootView.swift index 83858f5..f682432 100644 --- a/OSGKeyboardExt/Views/KeyboardRootView.swift +++ b/OSGKeyboardExt/Views/KeyboardRootView.swift @@ -3,29 +3,58 @@ // // 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 (centred) │ ← action cluster: +// │ [delete] [ space ] [return] │ mic + bottom row +// │ ┊ │ // └───────────────────────────────────────────┘ import SwiftUI import OSGKeyboardShared private enum KeyboardLayoutMetrics { - static let sideActionButtonSize: CGFloat = 53 - static let sideActionIconSize: CGFloat = 19 - static let sideSpaceBarWidth: CGFloat = 19 - static let micFlankMinSpacing: CGFloat = 36 - static let sideActionStackSpacing: CGFloat = 16 - /// Outer inset for delete / return·space from screen edges (8 pt → 24 pt, +200%). + static let micSize: CGFloat = 121 + static let micToButtonGap: CGFloat = 8 + static let bottomActionRowHeight: CGFloat = 48 + static let bottomActionFixedWidth: CGFloat = 86 + static let bottomActionSpacing: CGFloat = Spacing.xs + /// 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 the bottom action row 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 = 1 + static let topBarHeight: CGFloat = 38 + static let transcriptLineHeight: CGFloat = 22 + /// mic (121) + gap (8) + bottom row (48) = 177 pt + static let actionClusterHeight: CGFloat = micSize + micToButtonGap + bottomActionRowHeight + /// Gap between transcript line and mic (−30% from former 16 pt). + static let actionClusterTopGap: CGFloat = Spacing.md * 0.7 + /// Minimal gap below the bottom action row. + static let actionClusterBottomGap: CGFloat = Spacing.xs / 2 + + static var headerBandHeight: CGFloat { + topBarHeight + topBarToTranscriptSpacing + transcriptLineHeight + } + + /// 2 + 68 + 11.2 + 177 + 4 + 1 = 263.2 pt + static var totalHeight: CGFloat { + outerPaddingTop + + headerBandHeight + + actionClusterTopGap + + actionClusterHeight + + actionClusterBottomGap + + outerPaddingBottom + } } public struct KeyboardRootView: View { @@ -37,11 +66,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 +76,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.actionClusterTopGap) + + micActionRow + .frame(height: KeyboardLayoutMetrics.actionClusterHeight) + + Color.clear + .frame(height: KeyboardLayoutMetrics.actionClusterBottomGap) } - .padding(.top, 4) - .padding(.bottom, 6) + .padding(.top, KeyboardLayoutMetrics.outerPaddingTop) + .padding(.bottom, KeyboardLayoutMetrics.outerPaddingBottom) // 透明背景:让系统键盘 chrome 透出,不自行铺色(深浅模式一致)。 .background(Color.clear) .frame(height: Self.totalHeight) @@ -64,6 +96,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,11 +125,20 @@ public struct KeyboardRootView: View { } else { CloudEngineChip() } + if state.isPolishScenarioChipVisible { + ScenarioChip(state: state) + } LocaleChip(localeId: state.localeId) { newId in state.setLocale(newId) } + // 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)) @@ -92,59 +153,31 @@ public struct KeyboardRootView: View { .padding(.horizontal, Spacing.md) } - // MARK: - Centre area + // MARK: - Action cluster - 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) - } - - /// 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. + /// Mic centred above a bottom row: delete · space · return (or swapped). private var micActionRow: some View { - HStack(alignment: .center, spacing: 0) { - CircularToolbarButton(systemName: "delete.left", label: "delete") { - state.deleteBackward() - } - - Spacer(minLength: KeyboardLayoutMetrics.micFlankMinSpacing) + let editingBlocked = voiceInputBlocksEditing + let swapKeys = state.handednessPreference.swapsActionKeys + return VStack(spacing: KeyboardLayoutMetrics.micToButtonGap) { RecordButton( phase: buttonPhase, level: state.level, remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil, onToggle: state.tapMic ) - .frame(width: 132, height: 132) + .frame(width: KeyboardLayoutMetrics.micSize, height: KeyboardLayoutMetrics.micSize) - Spacer(minLength: KeyboardLayoutMetrics.micFlankMinSpacing) - - VStack(spacing: KeyboardLayoutMetrics.sideActionStackSpacing) { - CircularToolbarButton(systemName: "return", label: "newline") { - state.insertNewline() - } - CircularToolbarButton(spaceStyle: true, label: "space") { - state.insertSpace() + HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) { + if swapKeys { + bottomReturnButton(disabled: editingBlocked) + bottomSpaceButton(disabled: editingBlocked) + bottomDeleteButton(disabled: editingBlocked) + } else { + bottomDeleteButton(disabled: editingBlocked) + bottomSpaceButton(disabled: editingBlocked) + bottomReturnButton(disabled: editingBlocked) } } } @@ -152,6 +185,43 @@ public struct KeyboardRootView: View { .frame(maxWidth: .infinity) } + private func bottomDeleteButton(disabled: Bool) -> some View { + RepeatingDeleteButton(disabled: disabled) { + state.deleteBackward() + } + .frame( + width: KeyboardLayoutMetrics.bottomActionFixedWidth, + height: KeyboardLayoutMetrics.bottomActionRowHeight + ) + } + + private func bottomSpaceButton(disabled: Bool) -> some View { + RectangularToolbarButton(spaceStyle: true, label: "space", disabled: disabled) { + state.insertSpace() + } + .frame(height: KeyboardLayoutMetrics.bottomActionRowHeight) + } + + private func bottomReturnButton(disabled: Bool) -> some View { + RectangularToolbarButton(systemName: "return", label: "newline", disabled: disabled) { + state.insertNewline() + } + .frame( + width: KeyboardLayoutMetrics.bottomActionFixedWidth, + height: KeyboardLayoutMetrics.bottomActionRowHeight + ) + } + + /// Option C: block typing keys during the full voice-input pipeline. + private var voiceInputBlocksEditing: Bool { + switch state.phase { + case .requestingPermissions, .recording, .processing: + return true + case .idle, .error, .denied: + return false + } + } + private var buttonPhase: RecordButton.Phase { switch state.phase { case .idle: return .idle @@ -175,19 +245,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 @@ -226,13 +296,6 @@ private struct TranscriptLine: View { } .buttonStyle(.plain) .accessibilityHint(ExtL10n.text("keyboard.models.downloadHint")) - } else if isLocalEngine, localModelsReady, !localModelsLoaded { - HStack(spacing: 6) { - ProgressView().controlSize(.mini).tint(palette.textSecondary) - ExtL10n.text("keyboard.models.warming") - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - } } else if flowSessionActive { ExtL10n.text("keyboard.placeholder.idle") .font(TypeStyle.caption) @@ -310,115 +373,6 @@ private struct TranscriptLine: View { } } -// MARK: - Circular toolbar button - -private struct CircularToolbarButton: View { - @Environment(\.colorScheme) private var colorScheme - @Environment(\.themePalette) private var palette: ThemePalette - - let systemName: String? - let spaceStyle: Bool - let label: String - let action: () -> Void - - init(systemName: String, label: String, action: @escaping () -> Void) { - self.systemName = systemName - self.spaceStyle = false - self.label = label - self.action = action - } - - init(spaceStyle: Bool, label: String, action: @escaping () -> Void) { - self.systemName = nil - self.spaceStyle = spaceStyle - self.label = label - self.action = action - } - - var body: some View { - Button(action: action) { - Group { - if spaceStyle { - Capsule() - .fill(palette.textPrimary) - .frame(width: KeyboardLayoutMetrics.sideSpaceBarWidth, height: 3) - } else if let systemName { - Image(systemName: systemName) - .font(.system(size: KeyboardLayoutMetrics.sideActionIconSize, weight: .medium)) - .foregroundStyle(palette.textPrimary) - } - } - .frame(width: KeyboardLayoutMetrics.sideActionButtonSize, height: KeyboardLayoutMetrics.sideActionButtonSize) - .background(sideButtonFill, in: Circle()) - .overlay(Circle().stroke(palette.dividerStrong, lineWidth: 0.5)) - } - .buttonStyle(.plain) - .accessibilityLabel(Text(label)) - } - - private var sideButtonFill: Color { - colorScheme == .dark - ? Color(red: 0.20, green: 0.20, blue: 0.22) - : palette.surfaceElevated - } -} - -// 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, 3) - .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 { @@ -432,8 +386,8 @@ private struct CloudEngineChip: View { .font(TypeStyle.caption2) .foregroundStyle(palette.accent) .padding(.horizontal, Spacing.xs + 2) - .padding(.vertical, 5) - .frame(minHeight: 26) + .padding(.vertical, 6) + .frame(minHeight: 28) .background(palette.accent.opacity(0.15), in: Capsule()) .overlay(Capsule().stroke(palette.accent.opacity(0.35), lineWidth: 0.5)) } @@ -452,8 +406,8 @@ private struct LocalEngineChip: View { .font(TypeStyle.caption2) .foregroundStyle(palette.accent) .padding(.horizontal, Spacing.xs + 2) - .padding(.vertical, 5) - .frame(minHeight: 26) + .padding(.vertical, 6) + .frame(minHeight: 28) .background(palette.accent.opacity(0.15), in: Capsule()) .overlay(Capsule().stroke(palette.accent.opacity(0.35), lineWidth: 0.5)) } @@ -499,8 +453,8 @@ private struct LocaleChip: View { .font(TypeStyle.caption2) .foregroundStyle(palette.textPrimary) .padding(.horizontal, Spacing.xs + 2) - .padding(.vertical, 5) - .frame(minHeight: 26) + .padding(.vertical, 6) + .frame(minHeight: 28) .background(palette.surfaceElevated, in: Capsule()) .overlay(Capsule().stroke(palette.divider, lineWidth: 0.5)) } diff --git a/OSGKeyboardExt/Views/RecordButton.swift b/OSGKeyboardExt/Views/RecordButton.swift index d46cd7b..379b524 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 121 pt frame applied + /// by `KeyboardRootView` so glow / breath animations are not clipped. + private enum Layout { + static let disc: CGFloat = 95 + static let outerRing: CGFloat = 106 + static let breathRing: CGFloat = 100 + static let glow: CGFloat = 119 + } + 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) @@ -56,11 +65,11 @@ struct RecordButton: View { RadialGradient( colors: [palette.recordRed.opacity(0.55), .clear], center: .center, - startRadius: 50, - endRadius: 100 + startRadius: 46, + endRadius: 92 ) ) - .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() @@ -84,10 +93,10 @@ struct RecordButton: View { switch phase { case .idle: Image(systemName: "mic.fill") - .font(.system(size: 38, weight: .medium)) + .font(.system(size: 36, weight: .medium)) .foregroundStyle(.white) case .recording: - VStack(spacing: 4) { + VStack(spacing: 3) { if let remainingSeconds { Text(formatRemaining(remainingSeconds)) .font(.system(size: 22, weight: .semibold, design: .rounded)) @@ -100,7 +109,7 @@ struct RecordButton: View { color: Color(red: 1.0, green: 0.78, blue: 0.78), active: true ) - .frame(width: 72, height: 32) + .frame(width: 73, height: 32) } .transition(.opacity) case .processing: @@ -110,12 +119,12 @@ struct RecordButton: View { .scaleEffect(2.5) case .error: Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 30, weight: .medium)) + .font(.system(size: 32, weight: .medium)) .foregroundStyle(palette.warning) } } } - .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/ToolbarActionButtons.swift b/OSGKeyboardExt/Views/ToolbarActionButtons.swift new file mode 100644 index 0000000..16bca44 --- /dev/null +++ b/OSGKeyboardExt/Views/ToolbarActionButtons.swift @@ -0,0 +1,224 @@ +// ToolbarActionButtons.swift +// OSGKeyboard · Keyboard Extension +// +// Bottom-row action keys: repeating delete, space, and return. + +import SwiftUI +import UIKit +import OSGKeyboardShared + +// MARK: - Layout metrics + +private enum ToolbarButtonMetrics { + static let iconSize: CGFloat = 14 + static let cornerRadius: CGFloat = 12 + static let spaceBarCapsuleWidth: CGFloat = 31 + static let pressScale: CGFloat = 0.94 + static let pressOverlayOpacity: CGFloat = 0.18 +} + +// MARK: - Haptics + +private enum ToolbarHaptics { + @MainActor + static func tap() { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } +} + +// MARK: - Press styling + +private struct ToolbarKeyPressStyle: ButtonStyle { + let cornerRadius: CGFloat + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .overlay { + if configuration.isPressed { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(Color.black.opacity(ToolbarButtonMetrics.pressOverlayOpacity)) + } + } + .scaleEffect(configuration.isPressed ? ToolbarButtonMetrics.pressScale : 1) + .animation(.easeOut(duration: 0.1), value: configuration.isPressed) + .sensoryFeedback(.impact(weight: .light), trigger: configuration.isPressed) { _, pressed in + pressed + } + } +} + +private struct ToolbarKeySurface: View { + @Environment(\.colorScheme) private var colorScheme + @Environment(\.themePalette) private var palette + + let isPressed: Bool + let cornerRadius: CGFloat + @ViewBuilder let content: () -> Content + + var body: some View { + content() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(buttonFill, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(palette.dividerStrong, lineWidth: 0.5) + } + .overlay { + if isPressed { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(Color.black.opacity(ToolbarButtonMetrics.pressOverlayOpacity)) + } + } + .scaleEffect(isPressed ? ToolbarButtonMetrics.pressScale : 1) + .animation(.easeOut(duration: 0.1), value: isPressed) + } + + private var buttonFill: Color { + let base = colorScheme == .dark + ? Color(red: 0.20, green: 0.20, blue: 0.22) + : palette.surfaceElevated + return isPressed ? base.opacity(0.82) : base + } +} + +// MARK: - Repeating delete + +/// Tap deletes once; hold repeats with tiered acceleration after 5 s. +struct RepeatingDeleteButton: View { + @Environment(\.themePalette) private var palette + + let disabled: Bool + let action: () -> Void + + @State private var isPressing = false + @State private var repeatTask: Task? + @State private var repeatStartedAt: Date? + + private let initialDelay: TimeInterval = 0.4 + private let normalInterval: TimeInterval = 0.08 + private let accelTier2: TimeInterval = 0.05 + private let accelTier3: TimeInterval = 0.03 + private let accelTier4: TimeInterval = 0.015 + + var body: some View { + ToolbarKeySurface(isPressed: isPressing, cornerRadius: ToolbarButtonMetrics.cornerRadius) { + Image(systemName: "delete.left") + .font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold)) + .foregroundStyle(palette.textPrimary) + } + .contentShape(Rectangle()) + .gesture(pressGesture) + .opacity(disabled ? 0.38 : 1) + .allowsHitTesting(!disabled) + .accessibilityLabel(Text("delete")) + .accessibilityAddTraits(.isButton) + } + + private var pressGesture: some Gesture { + DragGesture(minimumDistance: 0) + .onChanged { _ in + guard !disabled, !isPressing else { return } + isPressing = true + repeatStartedAt = Date() + ToolbarHaptics.tap() + action() + startRepeating() + } + .onEnded { _ in + stopRepeating() + } + } + + private func interval(for elapsed: TimeInterval) -> TimeInterval { + if elapsed < 5 { return normalInterval } + if elapsed < 8 { return accelTier2 } + if elapsed < 12 { return accelTier3 } + return accelTier4 + } + + private func startRepeating() { + repeatTask?.cancel() + repeatTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(initialDelay * 1_000_000_000)) + guard !Task.isCancelled, isPressing else { return } + let anchor = repeatStartedAt ?? Date() + while !Task.isCancelled, isPressing { + action() + let elapsed = Date().timeIntervalSince(anchor) + let wait = interval(for: elapsed) + try? await Task.sleep(nanoseconds: UInt64(wait * 1_000_000_000)) + } + } + } + + private func stopRepeating() { + isPressing = false + repeatStartedAt = nil + repeatTask?.cancel() + repeatTask = nil + } +} + +// MARK: - Rectangular toolbar button + +struct RectangularToolbarButton: View { + @Environment(\.themePalette) private var palette + + let systemName: String? + let spaceStyle: Bool + let label: String + let disabled: Bool + let action: () -> Void + + init(systemName: String, label: String, disabled: Bool = false, action: @escaping () -> Void) { + self.systemName = systemName + self.spaceStyle = false + self.label = label + self.disabled = disabled + self.action = action + } + + init(spaceStyle: Bool, label: String, disabled: Bool = false, action: @escaping () -> Void) { + self.systemName = nil + self.spaceStyle = spaceStyle + self.label = label + self.disabled = disabled + self.action = action + } + + var body: some View { + Button(action: action) { + Group { + if spaceStyle { + Capsule() + .fill(palette.textPrimary) + .frame(width: ToolbarButtonMetrics.spaceBarCapsuleWidth, height: 3) + } else if let systemName { + Image(systemName: systemName) + .font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold)) + .foregroundStyle(palette.textPrimary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(keyBackground) + .overlay( + RoundedRectangle(cornerRadius: ToolbarButtonMetrics.cornerRadius, style: .continuous) + .stroke(palette.dividerStrong, lineWidth: 0.5) + ) + } + .buttonStyle(ToolbarKeyPressStyle(cornerRadius: ToolbarButtonMetrics.cornerRadius)) + .disabled(disabled) + .opacity(disabled ? 0.38 : 1) + .accessibilityLabel(Text(label)) + } + + @Environment(\.colorScheme) private var colorScheme + + private var keyBackground: some View { + let fill = colorScheme == .dark + ? Color(red: 0.20, green: 0.20, blue: 0.22) + : palette.surfaceElevated + return RoundedRectangle(cornerRadius: ToolbarButtonMetrics.cornerRadius, style: .continuous) + .fill(fill) + } +} diff --git a/OSGKeyboardExt/Views/TranslationChip.swift b/OSGKeyboardExt/Views/TranslationChip.swift new file mode 100644 index 0000000..5dcc082 --- /dev/null +++ b/OSGKeyboardExt/Views/TranslationChip.swift @@ -0,0 +1,137 @@ +// TranslationChip.swift +// OSGKeyboard · Keyboard Extension +// +// Compact chip rendered to the right of `LocaleChip` on the keyboard +// top bar. Doubles as both the on/off switch and the target-language +// picker — same Menu pattern as `LocaleChip` so muscle memory transfers. +// +// v0.2.1 follow-up: removed the explicit on/off toggle entry. The +// chip is now a pure picker over the 11 catalog rows (off + 10 +// locales); selecting "不翻译" turns translation off, selecting any +// locale turns it on with that target. `translationEnabled` is +// derived from the locale id so the chip / pipeline read the same +// source of truth. +// +// v0.2.1 final review: dropped the "needs cloud" warning state — +// both engines now run the translate-and-polish step (the local +// engine routes through DeepSeek via +// `ProviderConfig.localModeProviderId`). The chip is therefore just +// off / on, with the same accent treatment either way. +// +// Visual states: +// • 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` +// (Capsule + 28 pt min height + 6 pt vertical padding) so the top bar +// doesn't grow when translation is enabled. + +import SwiftUI +import OSGKeyboardShared + +struct TranslationChip: View { + @Environment(\.themePalette) private var palette: ThemePalette + + @ObservedObject var state: KeyboardViewController.State + + var body: some View { + Menu { + // v0.2.1 follow-up: pure picker over the full catalog, + // including `offLocaleId` at the top so "turn off" is one + // tap from any enabled state. Picking a row writes + // `translationTargetLocaleId`; `translationEnabled` is + // derived from it. + ForEach(TranslationLanguageCatalog.all) { language in + Button { + state.setTranslationTargetLocaleId(language.id) + } label: { + if language.id == currentSelectionId { + Label(displayLabel(for: language), systemImage: "checkmark") + } else { + Text(displayLabel(for: language)) + } + } + } + } label: { + label + } + .menuStyle(.button) + .accessibilityLabel(ExtL10n.text("keyboard.translation.a11y")) + .accessibilityHint(ExtL10n.text("keyboard.translation.a11yHint")) + } + + @ViewBuilder + private var label: some View { + let target = TranslationLanguageCatalog.resolve(state.translationTargetLocaleId) + let enabled = state.translationEnabled + + HStack(spacing: 4) { + Image(systemName: enabled ? "character.bubble" : "character.bubble.fill") + Text(chipLabel(target: target, enabled: enabled)) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + } + .font(TypeStyle.caption2) + .foregroundStyle(foreground(enabled: enabled)) + .padding(.horizontal, Spacing.xs + 2) + .padding(.vertical, 6) + .frame(minHeight: 28) + .background(background(enabled: enabled), in: Capsule()) + .overlay(Capsule().stroke(stroke(enabled: enabled), lineWidth: 0.5)) + } + + /// Active selection id — the chip derives "on" from a non-off + /// locale id, so reading `translationTargetLocaleId` is enough. + private var currentSelectionId: String { + state.translationTargetLocaleId + } + + private func displayLabel(for language: TranslationLanguage) -> String { + if language.id == TranslationLanguageCatalog.offLocaleId { + return ExtL10n.string("keyboard.translation.offMenu") + } + return language.nativeName + } + + private func chipLabel(target: TranslationLanguage, enabled: Bool) -> String { + if !enabled { + return ExtL10n.string("keyboard.translation.chip") + } + // Short form: "→EN" / "→日" style. Falls back to the prompt + // language name for languages without a chip-style abbreviation + // (e.g. French → "FR" via the 2-letter prefix). + let short = shortLabel(for: target) + return "→\(short)" + } + + private func shortLabel(for target: TranslationLanguage) -> String { + switch target.id { + case "en": return "EN" + case "zh-Hans": return "中" + case "zh-Hant": return "繁" + case "ja": return "日" + case "ko": return "韩" + case "fr": return "FR" + case "de": return "DE" + case "es": return "ES" + case "ru": return "RU" + case "pt": return "PT" + default: return target.promptLanguageName + } + } + + private func foreground(enabled: Bool) -> Color { + if enabled { return palette.accent } + return palette.textPrimary + } + + private func background(enabled: Bool) -> Color { + if enabled { return palette.accent.opacity(0.15) } + return palette.surfaceElevated + } + + private func stroke(enabled: Bool) -> Color { + if enabled { return palette.accent.opacity(0.35) } + return palette.divider + } +} \ No newline at end of file diff --git a/OSGKeyboardExt/en.lproj/Keyboard.strings b/OSGKeyboardExt/en.lproj/Keyboard.strings index 1f34ba2..58b9e65 100644 --- a/OSGKeyboardExt/en.lproj/Keyboard.strings +++ b/OSGKeyboardExt/en.lproj/Keyboard.strings @@ -121,7 +121,6 @@ "keyboard.placeholder.cloudBadge" = "Cloud"; "keyboard.models.notDownloaded" = "On-device models not downloaded"; "keyboard.models.downloadHint" = "Open OSGKeyboard to download models"; -"keyboard.models.warming" = "Loading models…"; "keyboard.rec" = "REC"; "keyboard.space" = "Space"; "keyboard.denied.mic" = "Mic denied"; @@ -177,6 +176,17 @@ "locale.chip.ja-JP" = "日"; "locale.chip.ko-KR" = "韩"; +/* 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"; "mode.transcribe" = "Transcribe"; diff --git a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings index c0cd6e9..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" = "语言"; @@ -121,7 +121,6 @@ "keyboard.placeholder.cloudBadge" = "云端"; "keyboard.models.notDownloaded" = "本地模型尚未下载"; "keyboard.models.downloadHint" = "打开 OSGKeyboard 下载模型"; -"keyboard.models.warming" = "正在加载模型…"; "keyboard.rec" = "REC"; "keyboard.space" = "空格"; "keyboard.denied.mic" = "麦克风被拒绝"; @@ -177,6 +176,17 @@ "locale.chip.ja-JP" = "日"; "locale.chip.ko-KR" = "韩"; +/* 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" = "关闭"; "mode.transcribe" = "转写"; diff --git a/OSGKeyboardShared/Models/FlowUtteranceChunkConfig.swift b/OSGKeyboardShared/Models/FlowUtteranceChunkConfig.swift index 10c8505..39da178 100644 --- a/OSGKeyboardShared/Models/FlowUtteranceChunkConfig.swift +++ b/OSGKeyboardShared/Models/FlowUtteranceChunkConfig.swift @@ -1,7 +1,7 @@ // FlowUtteranceChunkConfig.swift // OSGKeyboard · Shared // -// Chunking policy for pipelined Flow utterance ASR (up to 3 minutes). +// Chunking policy for pipelined Flow utterance ASR (up to 3.5 minutes). import Foundation diff --git a/OSGKeyboardShared/Models/HandednessPreference.swift b/OSGKeyboardShared/Models/HandednessPreference.swift new file mode 100644 index 0000000..1e6535c --- /dev/null +++ b/OSGKeyboardShared/Models/HandednessPreference.swift @@ -0,0 +1,29 @@ +// HandednessPreference.swift +// OSGKeyboard · Shared +// +// Which hand the user holds the phone with — controls bottom-row key order +// on the keyboard (delete ↔ return swap for right-handed use). + +import Foundation + +public enum HandednessPreference: String, CaseIterable, Identifiable, Sendable, Codable { + case left + case right + + public var id: String { rawValue } + + public var labelKey: String { + switch self { + case .left: return "settings.handedness.left" + case .right: return "settings.handedness.right" + } + } + + /// Right-handed preference places return on the left and delete on the right. + public var swapsActionKeys: Bool { self == .right } + + public static func fromStored(_ raw: String?) -> HandednessPreference { + guard let raw, let value = HandednessPreference(rawValue: raw) else { return .left } + return value + } +} diff --git a/OSGKeyboardShared/Models/LLMProvider.swift b/OSGKeyboardShared/Models/LLMProvider.swift index 53fe351..7e6fcdd 100644 --- a/OSGKeyboardShared/Models/LLMProvider.swift +++ b/OSGKeyboardShared/Models/LLMProvider.swift @@ -14,6 +14,12 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable { public let apiKeyURL: URL? /// Optional short blurb shown under the provider name in the picker. public let blurb: String? + /// Whether this preset should appear in user-facing provider pickers + /// (settings / onboarding). Defaults to `true` so the existing + /// `presets` array keeps its public surface area; future passes can + /// mark e.g. a DeepSeek key-pre-fill preset as `false` to hide it + /// from the picker without touching call sites. + public let isUserSelectable: Bool public init( id: String, @@ -21,7 +27,8 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable { defaultBaseURL: String, defaultModel: String, apiKeyURL: URL? = nil, - blurb: String? = nil + blurb: String? = nil, + isUserSelectable: Bool = true ) { self.id = id self.name = name @@ -29,6 +36,7 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable { self.defaultModel = defaultModel self.apiKeyURL = apiKeyURL self.blurb = blurb + self.isUserSelectable = isUserSelectable } public static let presets: [LLMProvider] = [ 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 d2f9cdb..f77822f 100644 --- a/OSGKeyboardShared/Models/ProviderConfig.swift +++ b/OSGKeyboardShared/Models/ProviderConfig.swift @@ -39,6 +39,20 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { // in the local engine. Default `false` — keeps the local engine // truly local unless the user explicitly opts in. static let localModeCloudPolishEnabled = "config.localModeCloudPolishEnabled" + // v0.2.1: optional translation step after ASR. The + // post-ASR transcript is routed through the same LLM with a + // translate-and-polish prompt targeting `translationTargetLocaleId`. + // Mutually exclusive with the local-only promise — see `TranslationPolicy`. + // + // v0.2.1 follow-up: `config.translationEnabled` was *removed* + // as a persisted key — translation is now derived from + // `translationTargetLocaleId` (== offLocaleId means "off"). The + // store still tolerates legacy reads of the old key so users + // who upgraded from a build that wrote it don't see a flash of + // "on" state during init, but new writes never touch the key. + static let translationTargetLocaleId = "config.translationTargetLocaleId" + static let polishScenarioId = "config.polishScenarioId" + static let handednessPreference = "config.handednessPreference" } @Published public var providerId: String { @@ -109,12 +123,83 @@ 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 { didSet { defaults.set(uiLanguage.rawValue, forKey: Key.uiLanguage) } } + /// v0.2.1: whether to translate the transcript into + /// `translationTargetLocaleId` before insertion. **Derived** — + /// translation is on iff the user has selected a target locale + /// (i.e. the persisted id is anything other than + /// `TranslationLanguageCatalog.offLocaleId`). Default off. + /// + /// This used to be a stored `@Published var ... { didSet }` but the + /// chip / picker now writes the locale directly; collapsing the + /// pair into one field removes the "two writes out of sync" bug + /// surface entirely. + public var translationEnabled: Bool { + translationTargetLocaleId != TranslationLanguageCatalog.offLocaleId + } + /// v0.2.1: BCP-47-ish target language id (e.g. `en`, `ja`, `ko`) the + /// translate-and-polish prompt should produce. Default `"off"` — + /// translation is opt-in. Persisted in the App Group so the keyboard + /// 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) + AppGroupConfigDarwin.postConfigChanged() + } + } + /// Selected polish scenario preset (e.g. `daily_chat`, `work`) or + /// `custom` when the user edits the raw system prompt. + @Published public var polishScenarioId: String { + didSet { + defaults.set(polishScenarioId, forKey: Key.polishScenarioId) + AppGroupConfigDarwin.postConfigChanged() + } + } + /// Which hand the user holds the phone with — mirrors to the keyboard + /// extension so delete / return can swap on the bottom row. + @Published public var handednessPreference: HandednessPreference { + didSet { + defaults.set(handednessPreference.rawValue, forKey: Key.handednessPreference) + AppGroupConfigDarwin.postConfigChanged() + } + } + + /// 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 { + guard translationEnabled else { return false } + if isLocalEngine { return localModeCloudPolishEnabled } + return true + } + + /// 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 { + 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 { // Local engine (on-device ASR only) doesn't need an API key, @@ -144,6 +229,16 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { isLocalEngine && localModeCloudPolishEnabled } + /// v0.2.1 follow-up: when the local engine is using the cloud- + /// polish step, route the call through DeepSeek — cheap, strong + /// on Chinese, and the right default for the on-device ASR + /// transcript. Other engines honor the user's configured + /// `providerId` unchanged so cloud users keep their preferred + /// vendor (OpenAI / Anthropic / Zhipu / etc). + public var localModeProviderId: String { + isLocalEngine ? "deepseek" : providerId + } + /// The system prompt the user *sees* in the editor — fall back to the /// provider-aware default from `AppGroupStore` when nothing is set. public var defaultSystemPrompt: String { @@ -193,6 +288,29 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { self.uiLanguage = AppUILanguage.fromStored( resolvedDefaults.string(forKey: Key.uiLanguage) ) + // v0.2.1 follow-up: `translationEnabled` is now derived from + // `translationTargetLocaleId` — no separate init read. + // Default the locale id to `offLocaleId` so existing installs + // that never picked a target language stay in the "off" state + // (the previous build's default of `"en"` would silently turn + // translation on for every upgraded user; off is the safe + // conservative default that matches the picker / chip UX). + self.translationTargetLocaleId = resolvedDefaults.string(forKey: Key.translationTargetLocaleId) + ?? TranslationLanguageCatalog.offLocaleId + if let storedScenario = resolvedDefaults.string(forKey: Key.polishScenarioId) { + self.polishScenarioId = PolishScenarioCatalog.resolve(storedScenario).id + } else { + let savedPrompt = resolvedDefaults.string(forKey: Key.systemPrompt) + ?? AppGroupStore.defaultSystemPrompt(for: pid) + if savedPrompt != AppGroupStore.defaultSystemPrompt(for: pid) { + self.polishScenarioId = PolishScenarioCatalog.customId + } else { + self.polishScenarioId = PolishScenarioCatalog.defaultId + } + } + self.handednessPreference = HandednessPreference.fromStored( + resolvedDefaults.string(forKey: Key.handednessPreference) + ) // Cloud no longer exposes off/transcribe; migrate legacy values. if self.engineMode == "cloud", self.modeId != "polish" { @@ -243,6 +361,8 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { apiKey = "" model = preset.defaultModel systemPrompt = AppGroupStore.defaultSystemPrompt(for: "openai") + polishScenarioId = PolishScenarioCatalog.defaultId + handednessPreference = .left hasAcknowledgedCloudSharing = false } } diff --git a/OSGKeyboardShared/Models/TranslationLanguage.swift b/OSGKeyboardShared/Models/TranslationLanguage.swift new file mode 100644 index 0000000..6768415 --- /dev/null +++ b/OSGKeyboardShared/Models/TranslationLanguage.swift @@ -0,0 +1,78 @@ +// TranslationLanguage.swift +// OSGKeyboard · Shared +// +// Catalog of target languages the translation feature can produce. +// +// Kept deliberately small (~10 entries) to match the kind of choices +// the user makes in the Settings picker / keyboard chip. We don't try +// to expose every BCP-47 locale — the prompt just needs a target +// language name, and a curated list reads better than a 100-row scroll. +// +// `id` is what gets persisted to the App Group. `promptLanguageName` +// is the human-readable target name injected into the prompt (e.g. +// the LLM sees "English", not "en"). `nativeName` is the endonym we +// show in the picker UI ("日本語" instead of "Japanese"). + +import Foundation + +public struct TranslationLanguage: Identifiable, Hashable, Sendable { + public let id: String + public let promptLanguageName: String + public let nativeName: String + + public init(id: String, promptLanguageName: String, nativeName: String) { + self.id = id + self.promptLanguageName = promptLanguageName + self.nativeName = nativeName + } +} + +public enum TranslationLanguageCatalog { + /// Sentinel id for "don't translate" — the default selection in the + /// picker. Picked over an `Optional` so the + /// single-row `Picker` binding stays a plain `String` (and the same + /// code path also works for the `TranslationChip` Menu). + public static let offLocaleId = "off" + /// Default target language id used on fresh installs when translation + /// is enabled. The picker still defaults to `offLocaleId` — this is + /// only the language we'd fall back to if a stale "on" state is + /// recovered without a remembered target. + public static let defaultLocaleId = "en" + + /// Curated set. Order matters — the picker / chip render top-to- + /// bottom, with `offLocaleId` ("不翻译") at the very top so the + /// "turn off" action is one tap away from any enabled state. + public static let all: [TranslationLanguage] = [ + TranslationLanguage(id: offLocaleId, promptLanguageName: "", nativeName: ""), + TranslationLanguage(id: "en", promptLanguageName: "English", nativeName: "English"), + TranslationLanguage(id: "zh-Hans", promptLanguageName: "Simplified Chinese", nativeName: "简体中文"), + TranslationLanguage(id: "zh-Hant", promptLanguageName: "Traditional Chinese", nativeName: "繁體中文"), + TranslationLanguage(id: "ja", promptLanguageName: "Japanese", nativeName: "日本語"), + TranslationLanguage(id: "ko", promptLanguageName: "Korean", nativeName: "한국어"), + TranslationLanguage(id: "fr", promptLanguageName: "French", nativeName: "Français"), + TranslationLanguage(id: "de", promptLanguageName: "German", nativeName: "Deutsch"), + TranslationLanguage(id: "es", promptLanguageName: "Spanish", nativeName: "Español"), + TranslationLanguage(id: "ru", promptLanguageName: "Russian", nativeName: "Русский"), + TranslationLanguage(id: "pt", promptLanguageName: "Portuguese", nativeName: "Português"), + ] + + /// True when the given id is the "off" sentinel. Used by the picker + /// to flip `translationEnabled` and by the pipeline to skip the + /// translate prompt. + public static func isOff(_ id: String) -> Bool { + id == offLocaleId + } + + /// Resolve a stored locale id to its catalog entry. Falls back to + /// `offLocaleId` (the picker default) when the id is missing or + /// unknown — matches the pattern used elsewhere (e.g. + /// `ASRLocaleLabels`) so the keyboard never crashes on a stale + /// persisted value, and the picker lands on the safe "off" state + /// instead of an arbitrary language. + public static func resolve(_ id: String) -> TranslationLanguage { + if let match = all.first(where: { $0.id == id }) { + return match + } + return all.first { $0.id == offLocaleId } ?? all[0] + } +} \ No newline at end of file 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 d2cc261..89dc3e3 100644 --- a/OSGKeyboardShared/Services/AppGroupStore.swift +++ b/OSGKeyboardShared/Services/AppGroupStore.swift @@ -39,6 +39,14 @@ public struct AppGroupStore: @unchecked Sendable { static let uiLanguage = "config.uiLanguage" // v0.2.0: opt-in cloud polish step after local-mode ASR. static let localModeCloudPolishEnabled = "config.localModeCloudPolishEnabled" + // v0.2.1 follow-up: `config.translationEnabled` was *removed* as a + // persisted key — translation is derived from the target locale + // id. New code should only write/read `translationTargetLocaleId`; + // the `translationEnabled` Bool accessor below is kept as a + // computed shim for source compatibility. + static let translationTargetLocaleId = "config.translationTargetLocaleId" + static let polishScenarioId = "config.polishScenarioId" + static let handednessPreference = "config.handednessPreference" } // MARK: - Reads @@ -104,6 +112,33 @@ public struct AppGroupStore: @unchecked Sendable { AppUILanguage.fromStored(defaults.string(forKey: Key.uiLanguage)) } + /// v0.2.1 follow-up: derived — translation is on iff a target locale + /// has been selected. The `translationTargetLocaleId` getter below + /// is the source of truth; this property exists for backwards + /// compatibility with call sites that read `store.translationEnabled`. + public var translationEnabled: Bool { + translationTargetLocaleId != TranslationLanguageCatalog.offLocaleId + } + + /// v0.2.1: target locale id the translate-and-polish prompt should + /// produce (e.g. `"en"`, `"ja"`). Defaults to `offLocaleId` ("off") + /// when nothing is stored, matching the picker / chip UX where the + /// user has to actively pick a language to turn translation on. + public var translationTargetLocaleId: String { + defaults.string(forKey: Key.translationTargetLocaleId) + ?? TranslationLanguageCatalog.offLocaleId + } + + public var polishScenarioId: String { + let stored = defaults.string(forKey: Key.polishScenarioId) + return PolishScenarioCatalog.resolve(stored ?? PolishScenarioCatalog.defaultId).id + } + + /// Bottom-row key order on the keyboard extension. + public var handednessPreference: HandednessPreference { + HandednessPreference.fromStored(defaults.string(forKey: Key.handednessPreference)) + } + // MARK: - Writes public func setModeId(_ id: String) { @@ -126,6 +161,97 @@ public struct AppGroupStore: @unchecked Sendable { defaults.set(language.rawValue, forKey: Key.uiLanguage) } + /// v0.2.1 follow-up: kept for source compatibility with callers that + /// still pass a Bool (e.g. older tests, any leftover bridge code). + /// `enabled == true` selects `defaultLocaleId` ("en") as a sensible + /// on-ramp target; `enabled == false` resets to `offLocaleId`. + /// The keyboard chip / pipeline now write the locale id directly + /// via `setTranslationTargetLocaleId`, which is the preferred path. + public func setTranslationEnabled(_ enabled: Bool) { + defaults.set( + enabled ? TranslationLanguageCatalog.defaultLocaleId : TranslationLanguageCatalog.offLocaleId, + forKey: Key.translationTargetLocaleId + ) + } + + /// v0.2.1: persist target locale id (e.g. `"en"`, `"ja"`, or + /// `TranslationLanguageCatalog.offLocaleId`). The keyboard + /// extension reads this on every `load()` and `refreshRuntimeFlags()` + /// so the chip reflects the latest value without a host-app + /// 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() + } + + public func setHandednessPreference(_ preference: HandednessPreference) { + defaults.set(preference.rawValue, forKey: Key.handednessPreference) + 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 public func makeClient() -> LLMClient { 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/FlowSessionKeys.swift b/OSGKeyboardShared/Services/FlowSessionKeys.swift index 3f0e976..3f78e93 100644 --- a/OSGKeyboardShared/Services/FlowSessionKeys.swift +++ b/OSGKeyboardShared/Services/FlowSessionKeys.swift @@ -24,8 +24,8 @@ public enum FlowSessionKeys { /// Default Flow session length when started from the keyboard. public static let defaultSessionDuration: TimeInterval = 480 - /// Maximum duration for a single keyboard utterance (3 minutes). - public static let maxUtteranceDuration: TimeInterval = 180 + /// Maximum duration for a single keyboard utterance (3.5 minutes). + public static let maxUtteranceDuration: TimeInterval = 210 /// Host polls for pipelined ASR drain after mic stop. Pipelining usually /// finishes most chunks during recording; this is a soft deadline before diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift index e0a23b4..40fd413 100644 --- a/OSGKeyboardShared/Services/KeyboardState.swift +++ b/OSGKeyboardShared/Services/KeyboardState.swift @@ -87,6 +87,43 @@ public final class KeyboardState: ObservableObject { /// CoreML local engine. Always `false` now — there are no weights /// for the host app to preload. @Published public var localModelsLoaded: Bool = false + /// v0.2.1 follow-up: derived — translation is on iff a target + /// locale has been selected (mirrors `ProviderConfig.translationEnabled` + /// so the chip / pipeline read the same source of truth). + public var translationEnabled: Bool { + translationTargetLocaleId != TranslationLanguageCatalog.offLocaleId + } + /// v0.2.1: target locale id the translate-and-polish prompt should + /// produce (e.g. `"en"`, `"ja"`). Mirrored from `ProviderConfig`. + /// Defaults to `offLocaleId` so the keyboard boots in the "off" + /// state on first install. + @Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId + /// Selected polish scenario mirrored from App Group. + @Published public var polishScenarioId: String = PolishScenarioCatalog.defaultId + /// v0.2.0: mirrored from App Group — local engine runs the cloud + /// LLM step only when this is `true`. + @Published public var localModeCloudPolishEnabled: Bool = false + /// Mirrored from App Group — swaps delete / return on the bottom row. + @Published public var handednessPreference: HandednessPreference = .left + /// Whether translate-and-polish is actually armed for the current + /// engine (local requires cloud polish + a target locale). + public var isTranslationEffective: Bool { + 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. public var isLocalEngine: Bool { engineMode == "local" } @@ -101,6 +138,11 @@ public final class KeyboardState: ObservableObject { public var setLocale: (String) -> Void = { _ in } public var setEngineMode: (String) -> Void = { _ in } public var setLocalASRBackend: (LocalASRBackend) -> Void = { _ in } + /// v0.2.1 follow-up: only the locale picker remains — `enabled` + /// 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 c607b73..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,13 +22,20 @@ 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 } + /// v0.2.1: what the LLM should do with the raw transcript. The + /// polish path stays the default so every existing call site keeps + /// its current behaviour — translation is opt-in via the `translate` + /// case and gets a target-locale parameter baked into the prompt. + public enum PolishMode: Equatable, Sendable { + case polish + case translate(targetLocaleId: String) + } + private let store: AppGroupStore private let timeout: TimeInterval /// Optional injected client (mostly for testing). When nil we build @@ -52,29 +57,81 @@ public actor PolishingService { self.timeout = timeout ?? (LLMClientFactory.defaultRequestTimeout + 1) } - public func polish(_ raw: String) async throws -> String { + /// v0.2.1 follow-up: `providerIdOverride` lets callers pin the + /// remote polish step to a specific provider (the local engine + /// pins to DeepSeek regardless of the user's chosen cloud + /// provider). Pass `nil` to honor `store.providerId` as before. + public func polish( + _ raw: String, + mode: PolishMode = .polish, + systemPrompt: String? = nil, + providerIdOverride: String? = nil + ) async throws -> String { 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 - } - return try await polishRemote(trimmed) + guard store.shouldRunCloudLLMStep else { return trimmed } + return try await polishRemote( + trimmed, + mode: mode, + systemPrompt: systemPrompt, + providerIdOverride: providerIdOverride + ) } - return try await polishRemote(trimmed) + return try await polishRemote( + trimmed, + mode: mode, + systemPrompt: systemPrompt, + providerIdOverride: providerIdOverride + ) } - private func polishRemote(_ trimmed: String) async throws -> String { - let client = injectedClient ?? store.makeClient() - let prompt = store.systemPrompt + private func polishRemote( + _ trimmed: String, + mode: PolishMode, + systemPrompt: String? = nil, + providerIdOverride: String? = nil + ) async throws -> String { + // v0.2.1 follow-up: when the caller pins a provider id (the + // local engine pins DeepSeek) we still want to honor the + // injected test client, but we have to re-derive the + // preset/baseURL/model/apiKey quartet from the *override* so + // the injected client gets the right values when it's nil. + let effectiveProviderId = providerIdOverride ?? store.providerId + let client: LLMClient + if let injectedClient { + client = injectedClient + } else { + let preset = LLMProvider.provider(id: effectiveProviderId) + let (baseURL, model) = Self.resolveLLMEndpoint( + store: store, + preset: preset, + providerIdOverride: providerIdOverride + ) + let apiKey: String + if effectiveProviderId == "deepseek" { + let preconfigured = PreconfiguredKeys.deepseek + if preconfigured == "TODO_FILL_LATER_DEEPSEEK_KEY" { + // Placeholder still in place — refuse the round- + // trip so the UI can surface a "build not + // configured" hint instead of a 401. + throw PolishError.missingAPIKey + } + apiKey = preconfigured + } else { + apiKey = store.apiKey + } + client = OpenAICompatibleClient(baseURL: baseURL, apiKey: apiKey, model: model) + } + let prompt = resolvedSystemPrompt( + for: mode, + override: systemPrompt, + providerId: effectiveProviderId + ) let budget = effectiveTimeout(for: trimmed) return try await withThrowingTaskGroup(of: String.self) { group in @@ -91,9 +148,75 @@ public actor PolishingService { } } + /// v0.2.1: pick the right system prompt for the requested mode. + /// Translation mode swaps in the parameterized translate-and-polish + /// prompt (see `TranslationPrompt.make`); polish mode keeps the + /// 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, + providerId: String? = nil + ) -> String { + if let override, !override.isEmpty { + return override + } + switch mode { + case .polish: + return store.resolvedPolishSystemPrompt(providerId: providerId) + case .translate(let targetLocaleId): + let target = TranslationLanguageCatalog.resolve(targetLocaleId) + let pid = providerId ?? store.providerId + return TranslationPrompt.make( + target: target, + providerId: pid, + scenarioId: store.polishScenarioId, + uiLanguage: store.uiLanguage + ) + } + } + /// Scale polish budget with transcript length (3-minute Flow utterances). private func effectiveTimeout(for text: String) -> TimeInterval { 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 new file mode 100644 index 0000000..f6a6e53 --- /dev/null +++ b/OSGKeyboardShared/Services/PreconfiguredKeys.swift @@ -0,0 +1,50 @@ +// PreconfiguredKeys.swift +// OSGKeyboard · Shared +// +// v0.2.1 follow-up: preconfigured API keys for built-in cloud providers +// the keyboard ships with out of the box. Today the only one is DeepSeek +// — the local engine's default polish vendor (see +// `ProviderConfig.localModeProviderId`). Future builds may pre-fill +// additional providers as we harden them. +// +// These constants live in source so a developer building from the repo +// can swap in their own key once and have every Debug / TestFlight build +// "just work" without round-tripping the Keychain settings UI. +// +// IMPORTANT: Replace the placeholder string with a real key before +// shipping a build. The DEBUG assert below catches the placeholder at +// launch so nobody accidentally publishes an "always 401" build. + +import Foundation + +public enum PreconfiguredKeys { + /// Placeholder string we ship in the repo. Any value other than + /// this is treated as "configured". + private static let placeholder = "TODO_FILL_LATER_DEEPSEEK_KEY" + + /// Preconfigured DeepSeek API key. Replace `placeholder` with a + /// real key in `Sources/.../PreconfiguredKeys.swift` before + /// distributing a build. + public static let deepseek: String = "REMOVED_LEAKED_DEEPSEEK_KEY" + + #if DEBUG + /// Forces a lazy init at app launch in DEBUG builds so the assert + /// below fires immediately when somebody forgets to swap the + /// placeholder. The boolean is intentionally unused at runtime — + /// it's a tripwire. + public static let isDeepseekConfigured: Bool = { + assert( + deepseek != placeholder, + "DeepSeek preconfigured key not filled — replace TODO_FILL_LATER_DEEPSEEK_KEY in PreconfiguredKeys.swift before building" + ) + return deepseek != placeholder + }() + + /// Touch the tripwire so the assert fires at launch rather than + /// only the first time the local engine actually tries to polish. + /// Called from app startup; safe to invoke multiple times. + public static func assertProductionReadinessAtLaunch() { + _ = isDeepseekConfigured + } + #endif +} 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 new file mode 100644 index 0000000..c42cc20 --- /dev/null +++ b/OSGKeyboardShared/Services/TranslationPrompt.swift @@ -0,0 +1,77 @@ +// TranslationPrompt.swift +// OSGKeyboard · Shared +// +// Builds the system prompt the LLM sees when the user has the +// translation toggle on. Re-uses the same per-provider "primary +// language" split the polish prompt uses (`AppGroupStore.defaultSystemPrompt`) +// so Chinese-native LLMs (DeepSeek, Qwen, GLM, Moonshot) get a Chinese +// prompt and English-native LLMs (OpenAI) get an English one — the LLM +// is most reliable when the instructions are written in its strongest +// language. +// +// The "translate AND polish" blend is intentional: ASR transcripts are +// noisy (homophone errors, broken segmentation, dropped particles), so +// the prompt asks the model to clean the noise while translating. +// Scenario output format (`ScenarioStyleDirective`) is appended so +// translate-and-polish honours the user's polish scenario choice. + +import Foundation + +public enum TranslationPrompt { + + /// Build the translate-and-polish system prompt. + /// + /// - Parameters: + /// - target: target language entry resolved via `TranslationLanguageCatalog`. + /// - providerId: provider preset id (e.g. `"deepseek"`, `"openai"`); + /// drives the language the prompt is written in. + /// - scenarioId: active polish scenario; format rules are shared + /// with the polish-only path via `ScenarioStyleDirective`. + /// - uiLanguage: host-app UI language (platform label variants). + public static func make( + target: TranslationLanguage, + providerId: String, + scenarioId: String = PolishScenarioCatalog.defaultId, + uiLanguage: AppUILanguage? = nil + ) -> String { + let isChineseNative = ["zhipu", "moonshot", "qwen", "deepseek"].contains(providerId) + 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, directive: String) -> String { + """ + 你是一位语音输入翻译与润色助手。用户用 ASR 转写了一段可能含噪声的口述: + 1) 先识别原话的主要语言(若不确定则按用户给定的方向处理); + 2) 将内容翻译为「\(target.promptLanguageName)」,保留原意,不增删事实、不臆测; + 3) 顺带修复 ASR 噪声(同音错字、漏字、断句错乱),让译文读起来自然; + 4) 简洁;若下方场景未要求列表/分段,不超过原文 1.5 倍;去掉无意义的口头禅(嗯、啊、那个); + 5) 若场景格式要求列表或分段,允许按格式组织译文;总长度不超过原长 2 倍; + 6) 只输出译文正文,不要解释、不要加引号、不要前缀"以下是翻译"。 + \(directive) + """ + } + + // MARK: - English prompt (for OpenAI / OpenAI-compatible non-Chinese) + + 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) 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) + """ + } +} 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 3c36ea9..05b7897 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.1" + CURRENT_PROJECT_VERSION: "6" # 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖) # 项目级签名 xcconfig,适用于所有 target @@ -128,6 +128,9 @@ targets: SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + # TestFlight upload: Automatic signing picks the right App Store profile. + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: X329MZU23S dependencies: - target: OSGKeyboardShared embed: true @@ -192,6 +195,9 @@ targets: SUPPORTS_MACCATALYST: NO SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO + # TestFlight upload: Automatic signing picks the right App Store profile. + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: X329MZU23S dependencies: - target: OSGKeyboardShared embed: false @@ -223,6 +229,10 @@ targets: DYLIB_INSTALL_NAME_BASE: "@rpath" APPLICATION_EXTENSION_API_ONLY: YES ENABLE_MODULE_VERIFIER: YES + # Frameworks inherit the host app's signing identity; no profile + # is required, but pinning the team keeps the build reproducible. + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: X329MZU23S dependencies: - sdk: Speech.framework - sdk: AVFoundation.framework