From 4fec0da7f04b7457f0cd2b71693f4923791c8ad1 Mon Sep 17 00:00:00 2001 From: Rocky Date: Thu, 25 Jun 2026 17:34:02 +0800 Subject: [PATCH] feat(translation-polish-2): local-engine dedicated section + PreconfiguredKeys for DeepSeek MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PreconfiguredKeys.swift: placeholder constant for the DeepSeek API key the local engine uses. DEBUG build asserts at launch when the placeholder is still in place so nobody ships an always-401 build by accident. Replace TODO_FILL_LATER_DEEPSEEK_KEY before distributing. - PolishingService.polishRemote: * Adopted (A) path: when effective provider is DeepSeek, take the apiKey from PreconfiguredKeys.deepseek instead of store.apiKey. Refuse the round-trip with PolishError.missingAPIKey when the placeholder is still in place. * Adopted fix for pre-existing typo: store.model.isEmpty ? preset.defaultModel : store.model (the right-hand side was defaultModel on both branches, silently ignoring the user's custom model field). - LocalEngineSettingsRows.LocalModelsGroup: * Dropped the inline 'uses DeepSeek' caption — it added visual weight without telling the user anything they couldn't infer. * Translation row now lives inside the group so the local engine reads as one cohesive card. * Group owns its surface card chrome (palette.surface + rounded border) — callers no longer need to wrap it externally. - SettingsView: * languageAndModelsSection: dropped the trailing TranslationPickerRow and the inner LocalModelsGroup (now lifted to its own section). * New localEngineSettingsSection: renders only when engineMode == 'local', wrapping LocalModelsGroup with a 'settings.localEngine.title' header. * body: added the new section between languageAndModelsSection and the cloud-only systemPromptLinkSection. - Localizable.strings (en + zh-Hans): * Removed settings.localModels.cloudPolish.caption (no references). * Added settings.localEngine.title = 'Local engine' / '本地引擎'. DeepSeek key pre-fill tripwire + local-engine UI cohesion. No main merge, no PR. --- .../Views/LocalEngineSettingsRows.swift | 45 ++++++++++------- OSGKeyboard/Views/SettingsView.swift | 29 +++++++---- OSGKeyboard/en.lproj/Localizable.strings | 2 +- OSGKeyboard/zh-Hans.lproj/Localizable.strings | 2 +- .../Services/PolishingService.swift | 25 ++++++++-- .../Services/PreconfiguredKeys.swift | 50 +++++++++++++++++++ 6 files changed, 118 insertions(+), 35 deletions(-) create mode 100644 OSGKeyboardShared/Services/PreconfiguredKeys.swift diff --git a/OSGKeyboard/Views/LocalEngineSettingsRows.swift b/OSGKeyboard/Views/LocalEngineSettingsRows.swift index dcd4c6d..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 @@ -58,28 +73,20 @@ struct LocalModelsGroup: View { /// key yet), but the polish call short-circuits with an Alert if /// the Keychain is empty when it fires. /// - /// v0.2.1 follow-up: added a one-line caption under the toggle - /// that names the default cloud vendor (DeepSeek) so the user - /// knows where the transcript is going when they flip the switch. - /// The toggle row is now a two-line layout — title + caption — - /// so we drop the explicit `singleLineMinHeight` here and let - /// `SettingsListMetrics` provide enough vertical room. + /// 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: 4) { - Toggle(isOn: $config.localModeCloudPolishEnabled) { - Text("settings.localModels.cloudPolish.title") - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) - } - .toggleStyle(.switch) - .tint(palette.accent) - Text("settings.localModels.cloudPolish.caption") - .font(TypeStyle.caption2) - .foregroundStyle(palette.textTertiary) - .fixedSize(horizontal: false, vertical: true) + 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.xs) + .frame(minHeight: SettingsListMetrics.singleLineMinHeight) } // MARK: Helpers diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift index ba7c7b0..96f42cc 100644 --- a/OSGKeyboard/Views/SettingsView.swift +++ b/OSGKeyboard/Views/SettingsView.swift @@ -74,6 +74,9 @@ struct SettingsView: View { apiSection } languageAndModelsSection + if config.engineMode == "local" { + localEngineSettingsSection + } if config.engineMode == "cloud" { systemPromptLinkSection } @@ -131,8 +134,9 @@ struct SettingsView: View { // now sits above the cloud-polish toggle / local models // block so the row that maps to microphone input comes // first, the row that maps to post-processing comes - // second, and translation (post-post-processing) sits at - // the bottom. + // second. Translation moved into the local-engine + // group (see `localEngineSettingsSection`) so the local + // engine reads as one cohesive card on its own. LocalePickerRow( locales: effectiveLocales, selection: Binding( @@ -140,14 +144,6 @@ struct SettingsView: View { set: { config.localeId = $0 } ) ) - if config.engineMode == "local" { - Divider().background(palette.divider) - LocalModelsGroup(config: config) - } - Divider().background(palette.divider) - if config.isTranslationRowVisible { - TranslationPickerRow(config: config, isVisible: true) - } } .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .overlay( @@ -178,6 +174,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") diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index 2e005d4..3284ec2 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -119,7 +119,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.localModels.cloudPolish.caption" = "Uses DeepSeek to polish transcripts in local mode."; +"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"; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index 91291ed..bfc7a87 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -119,7 +119,7 @@ "settings.localModels.readiness %lld %lld" = "%lld/%lld 已就绪"; "settings.localModels.cloudPolish.title" = "识别后云端润色"; "settings.localModels.cloudPolish.subtitle" = "将识别文本发送给已配置的云端大模型(默认 DeepSeek)进行润色。仅在 iOS 语音识别效果不理想时(远场、噪声、方言)开启,需提前在设置中填入 DeepSeek API Key。"; -"settings.localModels.cloudPolish.caption" = "本地模式下,使用 DeepSeek 进行云端润色。"; +"settings.localEngine.title" = "本地引擎"; "settings.language.subtitle.cloud" = "选择识别语言和文字处理模式。"; "settings.language.subtitle.local" = "选择识别语言。"; "settings.mode.title" = "模式"; diff --git a/OSGKeyboardShared/Services/PolishingService.swift b/OSGKeyboardShared/Services/PolishingService.swift index 4ed40b4..9cb517f 100644 --- a/OSGKeyboardShared/Services/PolishingService.swift +++ b/OSGKeyboardShared/Services/PolishingService.swift @@ -109,8 +109,8 @@ public actor PolishingService { // 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 triplet from the *override* so the - // injected client gets the right values when it's nil. + // 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 { @@ -118,8 +118,25 @@ public actor PolishingService { } else { let preset = LLMProvider.provider(id: effectiveProviderId) let baseURL = store.baseURL.isEmpty ? preset.defaultBaseURL : store.baseURL - let model = store.model.isEmpty ? preset.defaultModel : preset.defaultModel - client = OpenAICompatibleClient(baseURL: baseURL, apiKey: store.apiKey, model: model) + // Pre-existing typo fix: the user-overridden `store.model` + // path was returning `preset.defaultModel` on both branches, + // silently ignoring the user's custom model field. Restore + // the asymmetry so the user override actually wins. + let model = store.model.isEmpty ? preset.defaultModel : store.model + let 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) let budget = effectiveTimeout(for: trimmed) diff --git a/OSGKeyboardShared/Services/PreconfiguredKeys.swift b/OSGKeyboardShared/Services/PreconfiguredKeys.swift new file mode 100644 index 0000000..9656033 --- /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 = placeholder + + #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 +} \ No newline at end of file