feat(translation-polish-2): local-engine dedicated section + PreconfiguredKeys for DeepSeek
- 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.
This commit is contained in:
@@ -27,11 +27,26 @@ struct LocalModelsGroup: View {
|
|||||||
@ObservedObject var config: ProviderConfig
|
@ObservedObject var config: ProviderConfig
|
||||||
|
|
||||||
var body: some View {
|
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) {
|
VStack(spacing: 0) {
|
||||||
speechRow
|
speechRow
|
||||||
Divider().background(palette.divider)
|
Divider().background(palette.divider)
|
||||||
cloudPolishRow
|
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
|
// MARK: Speech row
|
||||||
@@ -58,28 +73,20 @@ struct LocalModelsGroup: View {
|
|||||||
/// key yet), but the polish call short-circuits with an Alert if
|
/// key yet), but the polish call short-circuits with an Alert if
|
||||||
/// the Keychain is empty when it fires.
|
/// the Keychain is empty when it fires.
|
||||||
///
|
///
|
||||||
/// v0.2.1 follow-up: added a one-line caption under the toggle
|
/// v0.2.1 follow-up: dropped the inline "uses DeepSeek" caption
|
||||||
/// that names the default cloud vendor (DeepSeek) so the user
|
/// (the user already opted into cloud mode by switching engines,
|
||||||
/// knows where the transcript is going when they flip the switch.
|
/// and the vendor name surfaces when they tap the row's helper
|
||||||
/// The toggle row is now a two-line layout — title + caption —
|
/// text in onboarding / deep links). Title + switch is enough.
|
||||||
/// so we drop the explicit `singleLineMinHeight` here and let
|
|
||||||
/// `SettingsListMetrics` provide enough vertical room.
|
|
||||||
private var cloudPolishRow: some View {
|
private var cloudPolishRow: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
Toggle(isOn: $config.localModeCloudPolishEnabled) {
|
||||||
Toggle(isOn: $config.localModeCloudPolishEnabled) {
|
Text("settings.localModels.cloudPolish.title")
|
||||||
Text("settings.localModels.cloudPolish.title")
|
.font(TypeStyle.body)
|
||||||
.font(TypeStyle.body)
|
.foregroundStyle(palette.textPrimary)
|
||||||
.foregroundStyle(palette.textPrimary)
|
|
||||||
}
|
|
||||||
.toggleStyle(.switch)
|
|
||||||
.tint(palette.accent)
|
|
||||||
Text("settings.localModels.cloudPolish.caption")
|
|
||||||
.font(TypeStyle.caption2)
|
|
||||||
.foregroundStyle(palette.textTertiary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
}
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
.tint(palette.accent)
|
||||||
.padding(.horizontal, Spacing.md)
|
.padding(.horizontal, Spacing.md)
|
||||||
.padding(.vertical, Spacing.xs)
|
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Helpers
|
// MARK: Helpers
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ struct SettingsView: View {
|
|||||||
apiSection
|
apiSection
|
||||||
}
|
}
|
||||||
languageAndModelsSection
|
languageAndModelsSection
|
||||||
|
if config.engineMode == "local" {
|
||||||
|
localEngineSettingsSection
|
||||||
|
}
|
||||||
if config.engineMode == "cloud" {
|
if config.engineMode == "cloud" {
|
||||||
systemPromptLinkSection
|
systemPromptLinkSection
|
||||||
}
|
}
|
||||||
@@ -131,8 +134,9 @@ struct SettingsView: View {
|
|||||||
// now sits above the cloud-polish toggle / local models
|
// now sits above the cloud-polish toggle / local models
|
||||||
// block so the row that maps to microphone input comes
|
// block so the row that maps to microphone input comes
|
||||||
// first, the row that maps to post-processing comes
|
// first, the row that maps to post-processing comes
|
||||||
// second, and translation (post-post-processing) sits at
|
// second. Translation moved into the local-engine
|
||||||
// the bottom.
|
// group (see `localEngineSettingsSection`) so the local
|
||||||
|
// engine reads as one cohesive card on its own.
|
||||||
LocalePickerRow(
|
LocalePickerRow(
|
||||||
locales: effectiveLocales,
|
locales: effectiveLocales,
|
||||||
selection: Binding(
|
selection: Binding(
|
||||||
@@ -140,14 +144,6 @@ struct SettingsView: View {
|
|||||||
set: { config.localeId = $0 }
|
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))
|
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||||
.overlay(
|
.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 {
|
private var providerSection: some View {
|
||||||
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
||||||
sectionHeader("settings.provider.title")
|
sectionHeader("settings.provider.title")
|
||||||
|
|||||||
@@ -119,7 +119,7 @@
|
|||||||
"settings.localModels.readiness %lld %lld" = "%lld/%lld ready";
|
"settings.localModels.readiness %lld %lld" = "%lld/%lld ready";
|
||||||
"settings.localModels.cloudPolish.title" = "Cloud polish after ASR";
|
"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.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.cloud" = "Recognition language and text processing mode.";
|
||||||
"settings.language.subtitle.local" = "Recognition language.";
|
"settings.language.subtitle.local" = "Recognition language.";
|
||||||
"settings.mode.title" = "Mode";
|
"settings.mode.title" = "Mode";
|
||||||
|
|||||||
@@ -119,7 +119,7 @@
|
|||||||
"settings.localModels.readiness %lld %lld" = "%lld/%lld 已就绪";
|
"settings.localModels.readiness %lld %lld" = "%lld/%lld 已就绪";
|
||||||
"settings.localModels.cloudPolish.title" = "识别后云端润色";
|
"settings.localModels.cloudPolish.title" = "识别后云端润色";
|
||||||
"settings.localModels.cloudPolish.subtitle" = "将识别文本发送给已配置的云端大模型(默认 DeepSeek)进行润色。仅在 iOS 语音识别效果不理想时(远场、噪声、方言)开启,需提前在设置中填入 DeepSeek API Key。";
|
"settings.localModels.cloudPolish.subtitle" = "将识别文本发送给已配置的云端大模型(默认 DeepSeek)进行润色。仅在 iOS 语音识别效果不理想时(远场、噪声、方言)开启,需提前在设置中填入 DeepSeek API Key。";
|
||||||
"settings.localModels.cloudPolish.caption" = "本地模式下,使用 DeepSeek 进行云端润色。";
|
"settings.localEngine.title" = "本地引擎";
|
||||||
"settings.language.subtitle.cloud" = "选择识别语言和文字处理模式。";
|
"settings.language.subtitle.cloud" = "选择识别语言和文字处理模式。";
|
||||||
"settings.language.subtitle.local" = "选择识别语言。";
|
"settings.language.subtitle.local" = "选择识别语言。";
|
||||||
"settings.mode.title" = "模式";
|
"settings.mode.title" = "模式";
|
||||||
|
|||||||
@@ -109,8 +109,8 @@ public actor PolishingService {
|
|||||||
// v0.2.1 follow-up: when the caller pins a provider id (the
|
// v0.2.1 follow-up: when the caller pins a provider id (the
|
||||||
// local engine pins DeepSeek) we still want to honor the
|
// local engine pins DeepSeek) we still want to honor the
|
||||||
// injected test client, but we have to re-derive the
|
// injected test client, but we have to re-derive the
|
||||||
// preset/baseURL/model triplet from the *override* so the
|
// preset/baseURL/model/apiKey quartet from the *override* so
|
||||||
// injected client gets the right values when it's nil.
|
// the injected client gets the right values when it's nil.
|
||||||
let effectiveProviderId = providerIdOverride ?? store.providerId
|
let effectiveProviderId = providerIdOverride ?? store.providerId
|
||||||
let client: LLMClient
|
let client: LLMClient
|
||||||
if let injectedClient {
|
if let injectedClient {
|
||||||
@@ -118,8 +118,25 @@ public actor PolishingService {
|
|||||||
} else {
|
} else {
|
||||||
let preset = LLMProvider.provider(id: effectiveProviderId)
|
let preset = LLMProvider.provider(id: effectiveProviderId)
|
||||||
let baseURL = store.baseURL.isEmpty ? preset.defaultBaseURL : store.baseURL
|
let baseURL = store.baseURL.isEmpty ? preset.defaultBaseURL : store.baseURL
|
||||||
let model = store.model.isEmpty ? preset.defaultModel : preset.defaultModel
|
// Pre-existing typo fix: the user-overridden `store.model`
|
||||||
client = OpenAICompatibleClient(baseURL: baseURL, apiKey: store.apiKey, model: 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 prompt = resolvedSystemPrompt(for: mode, override: systemPrompt)
|
||||||
let budget = effectiveTimeout(for: trimmed)
|
let budget = effectiveTimeout(for: trimmed)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user