feat(translation-v2): final review — dual-engine translation row + DeepSeek polish override + dead code cleanup
This commit is contained in:
@@ -58,20 +58,28 @@ 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: dropped the long descriptive subtitle — it explained
|
/// v0.2.1 follow-up: added a one-line caption under the toggle
|
||||||
/// things the user could read about elsewhere in Settings
|
/// that names the default cloud vendor (DeepSeek) so the user
|
||||||
/// (provider / API key section) and made the row visually heavy.
|
/// knows where the transcript is going when they flip the switch.
|
||||||
/// Title + switch is enough; details live in `CloudPolishDisclosureBanner`.
|
/// 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.
|
||||||
private var cloudPolishRow: some View {
|
private var cloudPolishRow: some View {
|
||||||
Toggle(isOn: $config.localModeCloudPolishEnabled) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("settings.localModels.cloudPolish.title")
|
Toggle(isOn: $config.localModeCloudPolishEnabled) {
|
||||||
.font(TypeStyle.body)
|
Text("settings.localModels.cloudPolish.title")
|
||||||
.foregroundStyle(palette.textPrimary)
|
.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)
|
||||||
}
|
}
|
||||||
.toggleStyle(.switch)
|
|
||||||
.tint(palette.accent)
|
|
||||||
.padding(.horizontal, Spacing.md)
|
.padding(.horizontal, Spacing.md)
|
||||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
.padding(.vertical, Spacing.xs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Helpers
|
// MARK: Helpers
|
||||||
|
|||||||
@@ -737,11 +737,11 @@ private struct APISetupPage: View {
|
|||||||
// v0.2.1 follow-up: translation row lives on the
|
// v0.2.1 follow-up: translation row lives on the
|
||||||
// onboarding engine page so first-time users can
|
// onboarding engine page so first-time users can
|
||||||
// pick a target language before they ever see the
|
// pick a target language before they ever see the
|
||||||
// keyboard. Wrapped in the section's surface card
|
// keyboard. v0.2.1 final review: both engines now
|
||||||
// so it sits flush with the engine / provider cards
|
// show the row (the local engine routes the polish
|
||||||
// above; visibility gated by `isTranslationRowVisible`
|
// step through DeepSeek, so the constraint is gone).
|
||||||
// so the local engine only shows it when cloud
|
// Wrapped in the same surface card chrome as the
|
||||||
// polish is also enabled.
|
// APISettingsCard above for visual symmetry.
|
||||||
if config.isTranslationRowVisible {
|
if config.isTranslationRowVisible {
|
||||||
translationSection
|
translationSection
|
||||||
.padding(.horizontal, Spacing.lg)
|
.padding(.horizontal, Spacing.lg)
|
||||||
@@ -767,10 +767,9 @@ private struct APISetupPage: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, Spacing.lg)
|
.padding(.horizontal, Spacing.lg)
|
||||||
// v0.2.1 follow-up: same conditional for the local
|
// v0.2.1 final review: same surface card chrome as
|
||||||
// branch — the row only renders when the engine
|
// the cloud branch — the row now renders for both
|
||||||
// can actually run the cloud translate-and-polish
|
// engines.
|
||||||
// step (i.e. cloud polish is opted in).
|
|
||||||
if config.isTranslationRowVisible {
|
if config.isTranslationRowVisible {
|
||||||
translationSection
|
translationSection
|
||||||
.padding(.horizontal, Spacing.lg)
|
.padding(.horizontal, Spacing.lg)
|
||||||
@@ -786,9 +785,14 @@ private struct APISetupPage: View {
|
|||||||
/// reads `ProviderConfig.translationTargetLocaleId` directly, so
|
/// reads `ProviderConfig.translationTargetLocaleId` directly, so
|
||||||
/// picking a locale in onboarding flows through to the keyboard
|
/// picking a locale in onboarding flows through to the keyboard
|
||||||
/// extension on the next `load()` cycle.
|
/// extension on the next `load()` cycle.
|
||||||
|
///
|
||||||
|
/// v0.2.1 final review: the section header now reads
|
||||||
|
/// `settings.translation.afterPolish` (renamed alongside the row
|
||||||
|
/// title in `TranslationPickerRow`) so the section and row read
|
||||||
|
/// as one cohesive group.
|
||||||
private var translationSection: some View {
|
private var translationSection: some View {
|
||||||
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
||||||
Text("settings.translation.title")
|
Text("settings.translation.afterPolish")
|
||||||
.font(TypeStyle.caption2)
|
.font(TypeStyle.caption2)
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
.textCase(.uppercase)
|
.textCase(.uppercase)
|
||||||
|
|||||||
@@ -13,13 +13,18 @@
|
|||||||
// inline hint was deleted along with the previous Bool toggle — the
|
// inline hint was deleted along with the previous Bool toggle — the
|
||||||
// user only sees the row when the engine can act on the choice.
|
// 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:
|
// Mapping to persisted state:
|
||||||
// • "不翻译" → translationTargetLocaleId = "off"
|
// • "不翻译" → translationTargetLocaleId = "off"
|
||||||
// • any specific locale → translationTargetLocaleId = <id>
|
// • any specific locale → translationTargetLocaleId = <id>
|
||||||
//
|
//
|
||||||
// The pipeline (`PolishingService`) still rejects `.translate` on the
|
// The pipeline (`PolishingService`) honors `.translate` on both
|
||||||
// local engine when cloud polish is off, which
|
// engines when this row is visible — no more "rejected mode" toast.
|
||||||
// `KeyboardViewController` surfaces as a 2.4s toast. Belt + suspenders.
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
@@ -37,7 +42,7 @@ struct TranslationPickerRow: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
if isVisible {
|
if isVisible {
|
||||||
HStack {
|
HStack {
|
||||||
Text("settings.translation.title")
|
Text("settings.translation.afterPolish")
|
||||||
.font(TypeStyle.body)
|
.font(TypeStyle.body)
|
||||||
.foregroundStyle(palette.textPrimary)
|
.foregroundStyle(palette.textPrimary)
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|||||||
@@ -109,7 +109,7 @@
|
|||||||
"settings.api.title" = "API";
|
"settings.api.title" = "API";
|
||||||
"settings.language.title" = "Language";
|
"settings.language.title" = "Language";
|
||||||
// v0.2.1: translation feature
|
// v0.2.1: translation feature
|
||||||
"settings.translation.title" = "Translation";
|
"settings.translation.afterPolish" = "Polish then translate";
|
||||||
"settings.translation.off" = "Don't translate";
|
"settings.translation.off" = "Don't translate";
|
||||||
"settings.languageModels.title" = "Language & models";
|
"settings.languageModels.title" = "Language & models";
|
||||||
"settings.localModels.title" = "On-device models";
|
"settings.localModels.title" = "On-device models";
|
||||||
@@ -119,6 +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.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";
|
||||||
|
|||||||
@@ -109,7 +109,7 @@
|
|||||||
"settings.api.title" = "接口";
|
"settings.api.title" = "接口";
|
||||||
"settings.language.title" = "语言";
|
"settings.language.title" = "语言";
|
||||||
// v0.2.1: 翻译功能
|
// v0.2.1: 翻译功能
|
||||||
"settings.translation.title" = "翻译";
|
"settings.translation.afterPolish" = "润色后翻译";
|
||||||
"settings.translation.off" = "不翻译";
|
"settings.translation.off" = "不翻译";
|
||||||
"settings.languageModels.title" = "语言与模型";
|
"settings.languageModels.title" = "语言与模型";
|
||||||
"settings.localModels.title" = "本地模型";
|
"settings.localModels.title" = "本地模型";
|
||||||
@@ -119,6 +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.language.subtitle.cloud" = "选择识别语言和文字处理模式。";
|
"settings.language.subtitle.cloud" = "选择识别语言和文字处理模式。";
|
||||||
"settings.language.subtitle.local" = "选择识别语言。";
|
"settings.language.subtitle.local" = "选择识别语言。";
|
||||||
"settings.mode.title" = "模式";
|
"settings.mode.title" = "模式";
|
||||||
|
|||||||
@@ -533,14 +533,28 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
// v0.2.1: pick the polish mode once at task start so a
|
// v0.2.1: pick the polish mode once at task start so a
|
||||||
// mid-flight toggle flip doesn't change the request we
|
// mid-flight toggle flip doesn't change the request we
|
||||||
// already sent. `isTranslationEffective` honours the cloud-
|
// already sent. `isTranslationEffective` no longer gates on
|
||||||
// only constraint so we never accidentally translate on the
|
// `engineMode == "cloud"` — the row visibility predicate
|
||||||
// local engine.
|
// already keeps the picker honest, and the local engine's
|
||||||
|
// translate-and-polish path now routes through DeepSeek.
|
||||||
let polishMode: PolishingService.PolishMode = self.state.isTranslationEffective
|
let polishMode: PolishingService.PolishMode = self.state.isTranslationEffective
|
||||||
? .translate(targetLocaleId: self.state.translationTargetLocaleId)
|
? .translate(targetLocaleId: self.state.translationTargetLocaleId)
|
||||||
: .polish
|
: .polish
|
||||||
|
// v0.2.1: local engine routes through DeepSeek for the
|
||||||
|
// polish / translate step regardless of the user's chosen
|
||||||
|
// cloud provider — DeepSeek is cheap and strong on
|
||||||
|
// Chinese, which is the dominant input for the on-device
|
||||||
|
// ASR transcript. Cloud engine honors the user's own
|
||||||
|
// provider id by passing `nil`.
|
||||||
|
let overrideProviderId: String? = self.state.engineMode == "local"
|
||||||
|
? "deepseek"
|
||||||
|
: nil
|
||||||
do {
|
do {
|
||||||
let polished = try await self.polisher.polish(trimmed, mode: polishMode)
|
let polished = try await self.polisher.polish(
|
||||||
|
trimmed,
|
||||||
|
mode: polishMode,
|
||||||
|
providerIdOverride: overrideProviderId
|
||||||
|
)
|
||||||
self.textDocumentProxy.insertText(polished)
|
self.textDocumentProxy.insertText(polished)
|
||||||
self.state.lastTranscript = ""
|
self.state.lastTranscript = ""
|
||||||
self.state.phase = .idle
|
self.state.phase = .idle
|
||||||
@@ -588,19 +602,6 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
message: ExtL10n.string("keyboard.error.llm.noApiKey")
|
message: ExtL10n.string("keyboard.error.llm.noApiKey")
|
||||||
)
|
)
|
||||||
self.scheduleAutoClearError()
|
self.scheduleAutoClearError()
|
||||||
} catch let polishError as PolishingService.PolishError where polishError == .translationNotAvailable {
|
|
||||||
// v0.2.1: user toggled translation on while the local
|
|
||||||
// engine is active. Fall back to a plain polish — and if
|
|
||||||
// we're on local-without-cloud-polish, fall all the way
|
|
||||||
// back to raw ASR. The keyboard surfaces a short hint
|
|
||||||
// telling them to switch to the cloud engine.
|
|
||||||
self.textDocumentProxy.insertText(trimmed)
|
|
||||||
self.state.lastTranscript = ""
|
|
||||||
self.state.phase = .error(
|
|
||||||
.unknown(ExtL10n.string("keyboard.error.translation.needsCloud")),
|
|
||||||
message: ExtL10n.string("keyboard.error.translation.needsCloud")
|
|
||||||
)
|
|
||||||
self.scheduleAutoClearError()
|
|
||||||
} catch {
|
} catch {
|
||||||
// Network / timeout / decoding — fall back to the raw
|
// Network / timeout / decoding — fall back to the raw
|
||||||
// transcript so the user still gets their text, with a
|
// transcript so the user still gets their text, with a
|
||||||
|
|||||||
@@ -12,12 +12,15 @@
|
|||||||
// derived from the locale id so the chip / pipeline read the same
|
// derived from the locale id so the chip / pipeline read the same
|
||||||
// source of truth.
|
// 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:
|
// Visual states:
|
||||||
// • off → dim outline, "翻译" label
|
// • off → dim outline, "翻译" label
|
||||||
// • on + cloud → accent fill, "→ EN" / "→ 日本語" style label
|
// • on (any engine) → accent fill, "→ EN" / "→ 日本語" style label
|
||||||
// • on + local engine→ warning fill + "翻译需云端" hint (effectively
|
|
||||||
// inert; the pipeline rejects the mode and the controller surfaces
|
|
||||||
// the error toast)
|
|
||||||
//
|
//
|
||||||
// Stays in the same visual family as `CloudEngineChip` / `LocaleChip`
|
// Stays in the same visual family as `CloudEngineChip` / `LocaleChip`
|
||||||
// (Capsule + 26 pt min height + 5 pt vertical padding) so the top bar
|
// (Capsule + 26 pt min height + 5 pt vertical padding) so the top bar
|
||||||
@@ -92,11 +95,10 @@ struct TranslationChip: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func chipLabel(target: TranslationLanguage, enabled: Bool, isLocal: Bool) -> String {
|
private func chipLabel(target: TranslationLanguage, enabled: Bool, isLocal: Bool) -> String {
|
||||||
// Local-engine + on shows the constraint hint instead of the
|
// v0.2.1 follow-up: with the local engine now routing the
|
||||||
// target label so the user knows why nothing's happening.
|
// polish / translate step through DeepSeek, the chip shows
|
||||||
if enabled, isLocal {
|
// the same "→EN"-style label on both engines. There's no
|
||||||
return ExtL10n.string("keyboard.translation.needsCloudShort")
|
// "needs cloud" hint path anymore.
|
||||||
}
|
|
||||||
if !enabled {
|
if !enabled {
|
||||||
return ExtL10n.string("keyboard.translation.off")
|
return ExtL10n.string("keyboard.translation.off")
|
||||||
}
|
}
|
||||||
@@ -124,19 +126,23 @@ struct TranslationChip: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func foreground(enabled: Bool, isLocal: Bool) -> Color {
|
private func foreground(enabled: Bool, isLocal: Bool) -> Color {
|
||||||
if enabled, isLocal { return palette.warning }
|
// v0.2.1 follow-up: the chip no longer needs a "warning" path
|
||||||
|
// for local + on — both engines share the accent treatment
|
||||||
|
// now. `isLocal` is kept in the signature so callers don't
|
||||||
|
// need to change; it's intentionally unused below.
|
||||||
|
_ = isLocal
|
||||||
if enabled { return palette.accent }
|
if enabled { return palette.accent }
|
||||||
return palette.textPrimary
|
return palette.textPrimary
|
||||||
}
|
}
|
||||||
|
|
||||||
private func background(enabled: Bool, isLocal: Bool) -> Color {
|
private func background(enabled: Bool, isLocal: Bool) -> Color {
|
||||||
if enabled, isLocal { return palette.warning.opacity(0.15) }
|
_ = isLocal
|
||||||
if enabled { return palette.accent.opacity(0.15) }
|
if enabled { return palette.accent.opacity(0.15) }
|
||||||
return palette.surfaceElevated
|
return palette.surfaceElevated
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stroke(enabled: Bool, isLocal: Bool) -> Color {
|
private func stroke(enabled: Bool, isLocal: Bool) -> Color {
|
||||||
if enabled, isLocal { return palette.warning.opacity(0.35) }
|
_ = isLocal
|
||||||
if enabled { return palette.accent.opacity(0.35) }
|
if enabled { return palette.accent.opacity(0.35) }
|
||||||
return palette.divider
|
return palette.divider
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,10 +181,8 @@
|
|||||||
"keyboard.translation.off" = "Translate";
|
"keyboard.translation.off" = "Translate";
|
||||||
"keyboard.translation.enable" = "Enable translation";
|
"keyboard.translation.enable" = "Enable translation";
|
||||||
"keyboard.translation.disable" = "Disable translation";
|
"keyboard.translation.disable" = "Disable translation";
|
||||||
"keyboard.translation.needsCloudShort" = "Need cloud";
|
|
||||||
"keyboard.translation.a11y" = "Translation";
|
"keyboard.translation.a11y" = "Translation";
|
||||||
"keyboard.translation.a11yHint" = "Toggle translation or change the target language.";
|
"keyboard.translation.a11yHint" = "Toggle translation or change the target language.";
|
||||||
"keyboard.error.translation.needsCloud" = "Translation needs the cloud engine — switch to cloud in Settings.";
|
|
||||||
|
|
||||||
/* Mode chip labels (used in both ext + preview stub) */
|
/* Mode chip labels (used in both ext + preview stub) */
|
||||||
"mode.off" = "Off";
|
"mode.off" = "Off";
|
||||||
|
|||||||
@@ -181,10 +181,8 @@
|
|||||||
"keyboard.translation.off" = "翻译";
|
"keyboard.translation.off" = "翻译";
|
||||||
"keyboard.translation.enable" = "开启翻译";
|
"keyboard.translation.enable" = "开启翻译";
|
||||||
"keyboard.translation.disable" = "关闭翻译";
|
"keyboard.translation.disable" = "关闭翻译";
|
||||||
"keyboard.translation.needsCloudShort" = "需云端";
|
|
||||||
"keyboard.translation.a11y" = "翻译";
|
"keyboard.translation.a11y" = "翻译";
|
||||||
"keyboard.translation.a11yHint" = "切换翻译开关或修改目标语言。";
|
"keyboard.translation.a11yHint" = "切换翻译开关或修改目标语言。";
|
||||||
"keyboard.error.translation.needsCloud" = "翻译需要云端引擎,请到设置切换为云端模式。";
|
|
||||||
|
|
||||||
/* Mode chip labels */
|
/* Mode chip labels */
|
||||||
"mode.off" = "关闭";
|
"mode.off" = "关闭";
|
||||||
|
|||||||
@@ -149,24 +149,22 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
|||||||
didSet { defaults.set(translationTargetLocaleId, forKey: Key.translationTargetLocaleId) }
|
didSet { defaults.set(translationTargetLocaleId, forKey: Key.translationTargetLocaleId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// v0.2.1: hard gate that decides whether the translation feature can
|
/// v0.2.1 follow-up: with the local engine's translate-and-polish
|
||||||
/// actually run. The keyboard honors `translationEnabled` only when
|
/// path now real (see `localModeProviderId`), `translationEnabled`
|
||||||
/// `engineMode == "cloud"` — the local engine is contractually ASR-
|
/// alone is enough to decide whether the pipeline should translate.
|
||||||
/// only, so translation is silently ignored (and the UI shows a hint)
|
/// Row visibility (`isTranslationRowVisible`) already gates the UI
|
||||||
/// even when the toggle is on.
|
/// on engines that can actually run the step, so we don't need to
|
||||||
|
/// re-check `engineMode` here.
|
||||||
public var isTranslationEffective: Bool {
|
public var isTranslationEffective: Bool {
|
||||||
translationEnabled && engineMode == "cloud"
|
translationEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
/// v0.2.1: row visibility predicate. Translation is shown only when
|
/// v0.2.1 follow-up: row visibility predicate. Both engines can
|
||||||
/// the engine can actually run the cloud translate-and-polish step:
|
/// now run the cloud translate-and-polish step (the local engine
|
||||||
/// - cloud engine: always visible
|
/// routes through DeepSeek via `localModeProviderId`), so the row
|
||||||
/// - local engine: visible only when cloud polish is also enabled
|
/// is shown whenever an engine mode is selected.
|
||||||
/// (otherwise translation is silently inert — the local engine
|
|
||||||
/// rejects `.translate` upstream, so we'd be advertising a
|
|
||||||
/// feature that can't run).
|
|
||||||
public var isTranslationRowVisible: Bool {
|
public var isTranslationRowVisible: Bool {
|
||||||
(engineMode == "cloud") || (engineMode == "local" && localModeCloudPolishEnabled)
|
engineMode == "local" || engineMode == "cloud"
|
||||||
}
|
}
|
||||||
|
|
||||||
public var isConfigured: Bool {
|
public var isConfigured: Bool {
|
||||||
@@ -197,6 +195,16 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
|||||||
isLocalEngine && localModeCloudPolishEnabled
|
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
|
/// The system prompt the user *sees* in the editor — fall back to the
|
||||||
/// provider-aware default from `AppGroupStore` when nothing is set.
|
/// provider-aware default from `AppGroupStore` when nothing is set.
|
||||||
public var defaultSystemPrompt: String {
|
public var defaultSystemPrompt: String {
|
||||||
|
|||||||
@@ -98,12 +98,14 @@ public final class KeyboardState: ObservableObject {
|
|||||||
/// Defaults to `offLocaleId` so the keyboard boots in the "off"
|
/// Defaults to `offLocaleId` so the keyboard boots in the "off"
|
||||||
/// state on first install.
|
/// state on first install.
|
||||||
@Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId
|
@Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId
|
||||||
/// v0.2.1: effective predicate — translation is honoured only on
|
/// v0.2.1: effective predicate — mirrors `ProviderConfig`.
|
||||||
/// the cloud engine. The keyboard's chip / picker read this so the
|
/// v0.2.1 follow-up: no longer gates on `engineMode == "cloud"`
|
||||||
/// UI can show a "需要云端" hint when the toggle is on while the
|
/// because the local engine's translate-and-polish step now runs
|
||||||
/// local engine is active.
|
/// (routed through DeepSeek). Row visibility (`isTranslationRowVisible`
|
||||||
|
/// on `ProviderConfig`) keeps the picker honest, so the keyboard
|
||||||
|
/// can rely on `translationEnabled` alone here.
|
||||||
public var isTranslationEffective: Bool {
|
public var isTranslationEffective: Bool {
|
||||||
translationEnabled && engineMode == "cloud"
|
translationEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience shorthand used by the pipeline and views.
|
/// Convenience shorthand used by the pipeline and views.
|
||||||
|
|||||||
@@ -29,10 +29,6 @@ public actor PolishingService {
|
|||||||
/// telling them to fill it in; we deliver the raw transcript
|
/// telling them to fill it in; we deliver the raw transcript
|
||||||
/// so no data is lost.
|
/// so no data is lost.
|
||||||
case missingAPIKey
|
case missingAPIKey
|
||||||
/// v0.2.1: the user requested translation but the active engine
|
|
||||||
/// can't honour it (e.g. `engineMode == "local"`). The keyboard
|
|
||||||
/// surfaces a short hint and falls back to the plain polish path.
|
|
||||||
case translationNotAvailable
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// v0.2.1: what the LLM should do with the raw transcript. The
|
/// v0.2.1: what the LLM should do with the raw transcript. The
|
||||||
@@ -65,20 +61,19 @@ public actor PolishingService {
|
|||||||
self.timeout = timeout ?? (LLMClientFactory.defaultRequestTimeout + 1)
|
self.timeout = timeout ?? (LLMClientFactory.defaultRequestTimeout + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func polish(_ raw: String, mode: PolishMode = .polish) 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)
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { throw PolishError.noTranscript }
|
guard !trimmed.isEmpty else { throw PolishError.noTranscript }
|
||||||
|
|
||||||
// Translation requires the cloud engine (and therefore an API
|
|
||||||
// key + base URL). When the user toggles translation on while
|
|
||||||
// the local engine is active we refuse the mode so the keyboard
|
|
||||||
// can fall back to a plain polish (or raw ASR) and surface a
|
|
||||||
// short hint. This keeps the local engine's "ASR only" promise
|
|
||||||
// intact.
|
|
||||||
if case .translate = mode, store.engineMode != "cloud" {
|
|
||||||
throw PolishError.translationNotAvailable
|
|
||||||
}
|
|
||||||
|
|
||||||
// Local engine: ASR-only unless the user opted into cloud
|
// Local engine: ASR-only unless the user opted into cloud
|
||||||
// polish via `localModeCloudPolishEnabled`. The cloud polish
|
// polish via `localModeCloudPolishEnabled`. The cloud polish
|
||||||
// path still requires an API key; if the Keychain is empty we
|
// path still requires an API key; if the Keychain is empty we
|
||||||
@@ -89,15 +84,44 @@ public actor PolishingService {
|
|||||||
guard !store.apiKey.isEmpty else {
|
guard !store.apiKey.isEmpty else {
|
||||||
throw PolishError.missingAPIKey
|
throw PolishError.missingAPIKey
|
||||||
}
|
}
|
||||||
return try await polishRemote(trimmed, mode: mode)
|
return try await polishRemote(
|
||||||
|
trimmed,
|
||||||
|
mode: mode,
|
||||||
|
systemPrompt: systemPrompt,
|
||||||
|
providerIdOverride: providerIdOverride
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return try await polishRemote(trimmed, mode: mode)
|
return try await polishRemote(
|
||||||
|
trimmed,
|
||||||
|
mode: mode,
|
||||||
|
systemPrompt: systemPrompt,
|
||||||
|
providerIdOverride: providerIdOverride
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func polishRemote(_ trimmed: String, mode: PolishMode) async throws -> String {
|
private func polishRemote(
|
||||||
let client = injectedClient ?? store.makeClient()
|
_ trimmed: String,
|
||||||
let prompt = resolvedSystemPrompt(for: mode)
|
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 triplet 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 = 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)
|
||||||
|
}
|
||||||
|
let prompt = resolvedSystemPrompt(for: mode, override: systemPrompt)
|
||||||
let budget = effectiveTimeout(for: trimmed)
|
let budget = effectiveTimeout(for: trimmed)
|
||||||
|
|
||||||
return try await withThrowingTaskGroup(of: String.self) { group in
|
return try await withThrowingTaskGroup(of: String.self) { group in
|
||||||
@@ -118,14 +142,19 @@ public actor PolishingService {
|
|||||||
/// Translation mode swaps in the parameterized translate-and-polish
|
/// Translation mode swaps in the parameterized translate-and-polish
|
||||||
/// prompt (see `TranslationPrompt.make`); polish mode keeps the
|
/// prompt (see `TranslationPrompt.make`); polish mode keeps the
|
||||||
/// existing `store.systemPrompt` behaviour so every other call site
|
/// existing `store.systemPrompt` behaviour so every other call site
|
||||||
/// is byte-identical to before.
|
/// is byte-identical to before. An explicit `override` wins over
|
||||||
private func resolvedSystemPrompt(for mode: PolishMode) -> String {
|
/// both paths so callers (and tests) can pin a specific prompt.
|
||||||
|
private func resolvedSystemPrompt(for mode: PolishMode, override: String? = nil) -> String {
|
||||||
|
if let override, !override.isEmpty {
|
||||||
|
return override
|
||||||
|
}
|
||||||
switch mode {
|
switch mode {
|
||||||
case .polish:
|
case .polish:
|
||||||
return store.systemPrompt
|
return store.systemPrompt
|
||||||
case .translate(let targetLocaleId):
|
case .translate(let targetLocaleId):
|
||||||
let target = TranslationLanguageCatalog.resolve(targetLocaleId)
|
let target = TranslationLanguageCatalog.resolve(targetLocaleId)
|
||||||
return TranslationPrompt.make(target: target, providerId: store.providerId)
|
let pid = store.providerId
|
||||||
|
return TranslationPrompt.make(target: target, providerId: pid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user