feat: polish scenarios and stabilize keyboard layout height

Add preset-driven polish scenarios (Settings, onboarding, ScenarioChip)
with ScenarioPrompt and style directives; drive keyboard height from
content (240pt) and use viewIsAppearing encapsulated-height offset for
smoother keyboard switches; remove redundant StatusBadge and hide system
dictation via hasDictationKey.
This commit is contained in:
Rocky
2026-06-29 00:08:00 +08:00
parent 4fec0da7f0
commit 1bdb8824ac
32 changed files with 1247 additions and 340 deletions
+25 -5
View File
@@ -560,11 +560,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 +581,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 +622,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")
+1 -27
View File
@@ -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 {
+13 -37
View File
@@ -734,23 +734,6 @@ private struct APISetupPage: View {
.padding(.horizontal, Spacing.lg)
APISettingsCard(config: config)
.padding(.horizontal, Spacing.lg)
// v0.2.1 follow-up: translation row lives on the
// onboarding engine page so first-time users can
// pick a target language before they ever see the
// keyboard. v0.2.1 final review: both engines now
// show the row (the local engine routes the polish
// step through DeepSeek, so the constraint is gone).
// Wrapped in the same surface card chrome as the
// 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 {
translationSection
.padding(.horizontal, Spacing.lg)
}
} else {
// v0.2.0: local engine is iOS `SpeechAnalyzer` only.
// Surface the cloud-polish toggle and a one-line
@@ -772,38 +755,31 @@ private struct APISetupPage: View {
)
}
.padding(.horizontal, Spacing.lg)
// v0.2.1 final review: same surface card chrome as
// the cloud branch the row now renders for both
// engines.
if config.isTranslationRowVisible {
translationSection
.padding(.horizontal, Spacing.lg)
}
}
if config.isPolishScenarioRowVisible {
postProcessingSection
.padding(.horizontal, Spacing.lg)
}
}
.padding(.bottom, Spacing.xxxl)
}
}
/// v0.2.1 follow-up: extracted so both engine branches can render
/// the same surface card + picker. `TranslationPickerRow` itself
/// reads `ProviderConfig.translationTargetLocaleId` directly, so
/// picking a locale in onboarding flows through to the keyboard
/// 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 {
/// Polish scenario + optional translation target for cloud onboarding.
private var postProcessingSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
Text("settings.translation.afterPolish")
Text("settings.polishScenario.section")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.textCase(.uppercase)
.frame(maxWidth: .infinity, alignment: .leading)
VStack(spacing: 0) {
TranslationPickerRow(config: config, isVisible: true)
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(
+59
View File
@@ -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)
}
}
+21 -35
View File
@@ -62,6 +62,7 @@ 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
@@ -73,13 +74,9 @@ struct SettingsView: View {
providerSection
apiSection
}
languageAndModelsSection
if config.engineMode == "local" {
localEngineSettingsSection
}
if config.engineMode == "cloud" {
systemPromptLinkSection
}
if presentation == .tab {
footerLinks
}
@@ -124,19 +121,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) {
// v0.2.1: language tab reorder ASR locale ("")
// 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. 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(
@@ -144,6 +134,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(
@@ -246,27 +253,6 @@ struct SettingsView: View {
dynamicLocales = entries
}
// MARK: - System prompt (cloud only)
private var systemPromptLinkSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.systemPrompt.title")
VStack(spacing: 0) {
NavigationLink {
SystemPromptSettingsView(config: config)
} label: {
footerNavigationRow(title: "settings.systemPrompt.edit")
}
.buttonStyle(.plain)
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
}
// MARK: - Footer links (tab settings only)
private var footerLinks: some View {
@@ -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)
+5
View File
@@ -108,9 +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";
@@ -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,9 +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" = "语音识别";