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:
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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" = "语音识别";
|
||||
|
||||
Reference in New Issue
Block a user