feat(translation-polish): dual-engine translation UX + provider picker filter + topbar cleanup

- Add LLMProvider.isUserSelectable (default true) and filter
  ProviderPickerSection on it; next pass can hide non-user presets
  (e.g. a future DeepSeek key-preset) without changing call sites.
- KeyboardRootView: hide TranslationChip when off (matches user's
  mental model of an opt-in feature), drop the 'warming' branch
  (Qwen3 download UX was removed with the backend in v0.2.0), unify
  chip pill height to minHeight 28 + vertical 6 for visual rhythm
  across all topbar chips.
- TranslationChip: drop isLocal warning path — both engines now
  run the translate-and-polish step (local routes through DeepSeek
  via ProviderConfig.localModeProviderId).
- OnboardingView: cloud engine branch now wraps the translation
  row in the same surface card chrome as the local branch.
- Strings: drop keyboard.models.warming (no longer referenced).

DeepSeek key pre-fill deferred to a follow-up.
This commit is contained in:
2026-06-25 15:16:04 +08:00
parent 0956bb8534
commit 93b6aa6c02
7 changed files with 46 additions and 43 deletions
+5
View File
@@ -742,6 +742,11 @@ private struct APISetupPage: View {
// step through DeepSeek, so the constraint is gone). // step through DeepSeek, so the constraint is gone).
// Wrapped in the same surface card chrome as the // Wrapped in the same surface card chrome as the
// APISettingsCard above for visual symmetry. // APISettingsCard above for visual symmetry.
//
// v0.2.1 final review (topbar cleanup pass): the
// surface card chrome is owned by `translationSection`
// itself, so both branches get it for free no
// per-branch duplication.
if config.isTranslationRowVisible { if config.isTranslationRowVisible {
translationSection translationSection
.padding(.horizontal, Spacing.lg) .padding(.horizontal, Spacing.lg)
@@ -10,15 +10,20 @@ struct ProviderPickerSection: View {
@ObservedObject var config: ProviderConfig @ObservedObject var config: ProviderConfig
var body: some View { 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) { 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 { Button {
select(provider) select(provider)
} label: { } label: {
row(provider, selected: provider.id == config.providerId) row(provider, selected: provider.id == config.providerId)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
if index < LLMProvider.presets.count - 1 { if index < visiblePresets.count - 1 {
Divider().background(palette.divider) Divider().background(palette.divider)
} }
} }
+14 -15
View File
@@ -80,7 +80,13 @@ public struct KeyboardRootView: View {
// and doubles as both the on/off switch and the target- // and doubles as both the on/off switch and the target-
// language picker (Menu pattern matches LocaleChip so the // language picker (Menu pattern matches LocaleChip so the
// top bar stays visually consistent). // top bar stays visually consistent).
TranslationChip(state: state) // v0.2.1 final review: only render the chip when translation
// is actually on. Off-by-default keeps the top bar compact
// for users who don't need translation; the menu still lives
// in onboarding so the feature is discoverable.
if state.translationEnabled {
TranslationChip(state: state)
}
Spacer(minLength: 0) Spacer(minLength: 0)
StatusBadge(phase: state.phase, onDeviceSupported: state.onDeviceSupported) StatusBadge(phase: state.phase, onDeviceSupported: state.onDeviceSupported)
Button(action: state.openSettings) { Button(action: state.openSettings) {
@@ -231,13 +237,6 @@ private struct TranscriptLine: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityHint(ExtL10n.text("keyboard.models.downloadHint")) .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 { } else if flowSessionActive {
ExtL10n.text("keyboard.placeholder.idle") ExtL10n.text("keyboard.placeholder.idle")
.font(TypeStyle.caption) .font(TypeStyle.caption)
@@ -418,7 +417,7 @@ private struct StatusBadge: View {
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
} }
.padding(.horizontal, Spacing.xs) .padding(.horizontal, Spacing.xs)
.padding(.vertical, 3) .padding(.vertical, 4)
.background(palette.surface, in: Capsule()) .background(palette.surface, in: Capsule())
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5)) .overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
} }
@@ -437,8 +436,8 @@ private struct CloudEngineChip: View {
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
.foregroundStyle(palette.accent) .foregroundStyle(palette.accent)
.padding(.horizontal, Spacing.xs + 2) .padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 5) .padding(.vertical, 6)
.frame(minHeight: 26) .frame(minHeight: 28)
.background(palette.accent.opacity(0.15), in: Capsule()) .background(palette.accent.opacity(0.15), in: Capsule())
.overlay(Capsule().stroke(palette.accent.opacity(0.35), lineWidth: 0.5)) .overlay(Capsule().stroke(palette.accent.opacity(0.35), lineWidth: 0.5))
} }
@@ -457,8 +456,8 @@ private struct LocalEngineChip: View {
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
.foregroundStyle(palette.accent) .foregroundStyle(palette.accent)
.padding(.horizontal, Spacing.xs + 2) .padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 5) .padding(.vertical, 6)
.frame(minHeight: 26) .frame(minHeight: 28)
.background(palette.accent.opacity(0.15), in: Capsule()) .background(palette.accent.opacity(0.15), in: Capsule())
.overlay(Capsule().stroke(palette.accent.opacity(0.35), lineWidth: 0.5)) .overlay(Capsule().stroke(palette.accent.opacity(0.35), lineWidth: 0.5))
} }
@@ -504,8 +503,8 @@ private struct LocaleChip: View {
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.xs + 2) .padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 5) .padding(.vertical, 6)
.frame(minHeight: 26) .frame(minHeight: 28)
.background(palette.surfaceElevated, in: Capsule()) .background(palette.surfaceElevated, in: Capsule())
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5)) .overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
} }
+11 -23
View File
@@ -23,7 +23,7 @@
// on (any engine) accent fill, " EN" / " " style label // on (any engine) accent fill, " EN" / " " style label
// //
// 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 + 28 pt min height + 6 pt vertical padding) so the top bar
// doesn't grow when translation is enabled. // doesn't grow when translation is enabled.
import SwiftUI import SwiftUI
@@ -63,22 +63,21 @@ struct TranslationChip: View {
@ViewBuilder @ViewBuilder
private var label: some View { private var label: some View {
let target = TranslationLanguageCatalog.resolve(state.translationTargetLocaleId) let target = TranslationLanguageCatalog.resolve(state.translationTargetLocaleId)
let isLocal = state.isLocalEngine
let enabled = state.translationEnabled let enabled = state.translationEnabled
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: enabled ? "character.bubble" : "character.bubble.fill") Image(systemName: enabled ? "character.bubble" : "character.bubble.fill")
Text(chipLabel(target: target, enabled: enabled, isLocal: isLocal)) Text(chipLabel(target: target, enabled: enabled))
Image(systemName: "chevron.down") Image(systemName: "chevron.down")
.font(.system(size: 8, weight: .bold)) .font(.system(size: 8, weight: .bold))
} }
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
.foregroundStyle(foreground(enabled: enabled, isLocal: isLocal)) .foregroundStyle(foreground(enabled: enabled))
.padding(.horizontal, Spacing.xs + 2) .padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 5) .padding(.vertical, 6)
.frame(minHeight: 26) .frame(minHeight: 28)
.background(background(enabled: enabled, isLocal: isLocal), in: Capsule()) .background(background(enabled: enabled), in: Capsule())
.overlay(Capsule().stroke(stroke(enabled: enabled, isLocal: isLocal), lineWidth: 0.5)) .overlay(Capsule().stroke(stroke(enabled: enabled), lineWidth: 0.5))
} }
/// Active selection id the chip derives "on" from a non-off /// Active selection id the chip derives "on" from a non-off
@@ -94,11 +93,7 @@ struct TranslationChip: View {
return language.nativeName return language.nativeName
} }
private func chipLabel(target: TranslationLanguage, enabled: Bool, isLocal: Bool) -> String { private func chipLabel(target: TranslationLanguage, enabled: Bool) -> String {
// v0.2.1 follow-up: with the local engine now routing the
// polish / translate step through DeepSeek, the chip shows
// the same "EN"-style label on both engines. There's no
// "needs cloud" hint path anymore.
if !enabled { if !enabled {
return ExtL10n.string("keyboard.translation.off") return ExtL10n.string("keyboard.translation.off")
} }
@@ -125,24 +120,17 @@ struct TranslationChip: View {
} }
} }
private func foreground(enabled: Bool, isLocal: Bool) -> Color { private func foreground(enabled: Bool) -> Color {
// 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) -> Color {
_ = 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) -> Color {
_ = isLocal
if enabled { return palette.accent.opacity(0.35) } if enabled { return palette.accent.opacity(0.35) }
return palette.divider return palette.divider
} }
-1
View File
@@ -121,7 +121,6 @@
"keyboard.placeholder.cloudBadge" = "Cloud"; "keyboard.placeholder.cloudBadge" = "Cloud";
"keyboard.models.notDownloaded" = "On-device models not downloaded"; "keyboard.models.notDownloaded" = "On-device models not downloaded";
"keyboard.models.downloadHint" = "Open OSGKeyboard to download models"; "keyboard.models.downloadHint" = "Open OSGKeyboard to download models";
"keyboard.models.warming" = "Loading models…";
"keyboard.rec" = "REC"; "keyboard.rec" = "REC";
"keyboard.space" = "Space"; "keyboard.space" = "Space";
"keyboard.denied.mic" = "Mic denied"; "keyboard.denied.mic" = "Mic denied";
@@ -121,7 +121,6 @@
"keyboard.placeholder.cloudBadge" = "云端"; "keyboard.placeholder.cloudBadge" = "云端";
"keyboard.models.notDownloaded" = "本地模型尚未下载"; "keyboard.models.notDownloaded" = "本地模型尚未下载";
"keyboard.models.downloadHint" = "打开 OSGKeyboard 下载模型"; "keyboard.models.downloadHint" = "打开 OSGKeyboard 下载模型";
"keyboard.models.warming" = "正在加载模型…";
"keyboard.rec" = "REC"; "keyboard.rec" = "REC";
"keyboard.space" = "空格"; "keyboard.space" = "空格";
"keyboard.denied.mic" = "麦克风被拒绝"; "keyboard.denied.mic" = "麦克风被拒绝";
+9 -1
View File
@@ -14,6 +14,12 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
public let apiKeyURL: URL? public let apiKeyURL: URL?
/// Optional short blurb shown under the provider name in the picker. /// Optional short blurb shown under the provider name in the picker.
public let blurb: String? 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( public init(
id: String, id: String,
@@ -21,7 +27,8 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
defaultBaseURL: String, defaultBaseURL: String,
defaultModel: String, defaultModel: String,
apiKeyURL: URL? = nil, apiKeyURL: URL? = nil,
blurb: String? = nil blurb: String? = nil,
isUserSelectable: Bool = true
) { ) {
self.id = id self.id = id
self.name = name self.name = name
@@ -29,6 +36,7 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
self.defaultModel = defaultModel self.defaultModel = defaultModel
self.apiKeyURL = apiKeyURL self.apiKeyURL = apiKeyURL
self.blurb = blurb self.blurb = blurb
self.isUserSelectable = isUserSelectable
} }
public static let presets: [LLMProvider] = [ public static let presets: [LLMProvider] = [