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:
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Polish scenarios**: pick a writing context (Daily Chat, Social Network / 小红书, Instagram / 微博, Goofy, Work, Document, TODO, Custom) in Settings, onboarding, and the keyboard top-bar `ScenarioChip`. Presets drive `ScenarioPrompt`; Custom reuses the system prompt editor.
|
||||
|
||||
### Changed
|
||||
- **Scenario output formats**: shared `ScenarioStyleDirective` enforces structural rules (Work → mandatory bullets for multi-item input, TODO → checklist). The same directive applies to translate-and-polish via `TranslationPrompt`.
|
||||
|
||||
## [0.3.0] - 2026-06-24
|
||||
|
||||
### Added
|
||||
- **Post-polish translation** for cloud and local engines: target-language picker in Settings / onboarding, `TranslationChip` on the keyboard top bar, and `PolishMode.translate` in `PolishingService`.
|
||||
- **Preconfigured DeepSeek key** (`PreconfiguredKeys`) for local-engine cloud polish without round-tripping the Settings API card.
|
||||
|
||||
### Changed
|
||||
- **Local-engine translation is gated on cloud polish**: the translation row and LLM step are hidden/disabled until "Cloud polish after ASR" is enabled; turning polish off clears a stale translation target.
|
||||
- **Local-engine LLM endpoint pinning**: when the pipeline routes through DeepSeek, base URL and model come from the DeepSeek preset instead of the user's cloud-provider settings (fixes DeepSeek key + Qwen URL 401s).
|
||||
|
||||
### Fixed
|
||||
- **Translation chip always visible** when the engine can run cloud LLM (cloud always; local when cloud polish is on) — no longer hidden when target is "不翻译".
|
||||
- **Keyboard translation menu** first item shows "不翻译"; chip label when off stays "翻译".
|
||||
- **Translation toggle race**: 2.5s protect window after chip writes, Darwin config notification, host finalize re-reads App Group; turning off cloud polish no longer clears saved translation target.
|
||||
|
||||
## [0.2.1] - 2026-06-24
|
||||
|
||||
### Removed
|
||||
|
||||
@@ -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" = "语音识别";
|
||||
|
||||
@@ -74,24 +74,36 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
private var wasFlowSessionActive = false
|
||||
private var flowSessionMonitorTask: Task<Void, Never>?
|
||||
private var flowSessionDarwinObserver: FlowSessionDarwinObserver?
|
||||
private var configDarwinObserver: FlowSessionDarwinObserver?
|
||||
/// Grace period after a chip-side translation write during which the
|
||||
/// 1 Hz App Group poll must not overwrite `translationTargetLocaleId`.
|
||||
private var translationConfigProtectedUntil: Date?
|
||||
private var polishScenarioConfigProtectedUntil: Date?
|
||||
private var isAwaitingFlowResult = false
|
||||
private var lastFlowAutoStartAttempt: TimeInterval = 0
|
||||
private static let flowAutoStartCooldown: TimeInterval = 20
|
||||
/// Drives the keyboard slot height on `view` (priority 999).
|
||||
private var keyboardHeightConstraint: NSLayoutConstraint?
|
||||
/// Runtime value read from `UIView-Encapsulated-Layout-Height` (varies by device).
|
||||
private var systemEncapsulatedHeight: CGFloat = 228
|
||||
|
||||
private var targetKeyboardHeight: CGFloat {
|
||||
KeyboardRootView.totalHeight
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
// Keyboard extension MUST opt in to self-sizing, otherwise
|
||||
// our SwiftUI `frame(height:)` is ignored and the keyboard is
|
||||
// cropped by the system chrome (Spotlight bar, home indicator).
|
||||
inputView?.allowsSelfSizing = true
|
||||
installKeyboardHeight()
|
||||
configureDictationBehavior()
|
||||
installStateActions()
|
||||
installSwiftUI()
|
||||
loadPersistedConfig()
|
||||
consumePendingDictationResultIfNeeded()
|
||||
refreshDictationProgressStateIfNeeded()
|
||||
installFlowSessionDarwinObserver()
|
||||
installConfigDarwinObserver()
|
||||
refreshFlowSessionState()
|
||||
}
|
||||
|
||||
@@ -108,6 +120,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
public override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
configureDictationBehavior()
|
||||
KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess)
|
||||
consumePendingDictationResultIfNeeded()
|
||||
refreshDictationProgressStateIfNeeded()
|
||||
@@ -115,6 +128,17 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
startFlowSessionMonitor()
|
||||
}
|
||||
|
||||
public override func viewIsAppearing(_ animated: Bool) {
|
||||
super.viewIsAppearing(animated)
|
||||
applyPresentationHeightOffset()
|
||||
}
|
||||
|
||||
public override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
// Presentation finished — lock to the true content-driven height.
|
||||
keyboardHeightConstraint?.constant = targetKeyboardHeight
|
||||
}
|
||||
|
||||
public override func didReceiveMemoryWarning() {
|
||||
super.didReceiveMemoryWarning()
|
||||
cancelPipeline()
|
||||
@@ -126,6 +150,14 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
refreshDictationProgressStateIfNeeded()
|
||||
}
|
||||
|
||||
// MARK: - System keyboard chrome
|
||||
|
||||
/// Tell iOS this keyboard provides its own dictation entry (centre mic).
|
||||
/// When `true`, the system dictation key in the bottom-right is not shown.
|
||||
private func configureDictationBehavior() {
|
||||
hasDictationKey = true
|
||||
}
|
||||
|
||||
// MARK: - Wiring
|
||||
|
||||
private func installStateActions() {
|
||||
@@ -141,16 +173,49 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
// v0.2.1 follow-up: removed `setTranslationEnabled` — the chip
|
||||
// / picker only writes the locale id now; `enabled` is derived.
|
||||
state.setTranslationTargetLocaleId = { [weak self] id in self?.persistTranslationTargetLocaleId(id) }
|
||||
state.setPolishScenarioId = { [weak self] id in self?.persistPolishScenarioId(id) }
|
||||
state.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") }
|
||||
state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") }
|
||||
state.deleteBackward = { [weak self] in self?.textDocumentProxy.deleteBackward() }
|
||||
}
|
||||
|
||||
/// Reserve keyboard height on `view`. During presentation iOS adds a
|
||||
/// private encapsulated height; `viewIsAppearing` applies the community
|
||||
/// offset trick (target − encapsulated) so the slot lands at `target`.
|
||||
private func installKeyboardHeight() {
|
||||
let constraint = view.heightAnchor.constraint(
|
||||
equalToConstant: targetKeyboardHeight
|
||||
)
|
||||
constraint.priority = UILayoutPriority(999)
|
||||
constraint.isActive = true
|
||||
keyboardHeightConstraint = constraint
|
||||
}
|
||||
|
||||
/// Read the system encapsulated height and prime our constraint so iOS
|
||||
/// presentation math (custom + encapsulated) equals `targetKeyboardHeight`.
|
||||
/// See: https://developer.apple.com/forums/thread/799003
|
||||
private func applyPresentationHeightOffset() {
|
||||
if let encapsulated = view.constraints.first(where: { constraint in
|
||||
constraint.firstItem as? UIView === view
|
||||
&& constraint.firstAttribute == .height
|
||||
&& constraint !== keyboardHeightConstraint
|
||||
}) {
|
||||
systemEncapsulatedHeight = encapsulated.constant
|
||||
}
|
||||
let primed = targetKeyboardHeight - systemEncapsulatedHeight
|
||||
keyboardHeightConstraint?.constant = max(0, primed)
|
||||
}
|
||||
|
||||
private func installSwiftUI() {
|
||||
let root = KeyboardRootView(state: state)
|
||||
let host = UIHostingController(rootView: root)
|
||||
host.view.backgroundColor = .clear
|
||||
host.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
host.view.clipsToBounds = false
|
||||
// Keep keyboard layout anchored to the top edge across keyboard
|
||||
// switches — don't let UIHostingController re-inset for safe area.
|
||||
host.view.insetsLayoutMarginsFromSafeArea = false
|
||||
host.safeAreaRegions = []
|
||||
addChild(host)
|
||||
view.addSubview(host.view)
|
||||
NSLayoutConstraint.activate([
|
||||
@@ -158,11 +223,6 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
host.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
// Pin the host view to a fixed height matching KeyboardRootView.totalHeight.
|
||||
// Without this, iOS lets the system chrome (Spotlight, home
|
||||
// indicator) bleed into our content. With it, our content area
|
||||
// is fully reserved and the keyboard feels intentional.
|
||||
host.view.heightAnchor.constraint(equalToConstant: KeyboardRootView.totalHeight)
|
||||
])
|
||||
host.didMove(toParent: self)
|
||||
self.hosting = host
|
||||
@@ -203,8 +263,28 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
flowSessionMonitorTask = nil
|
||||
}
|
||||
|
||||
private func installConfigDarwinObserver() {
|
||||
configDarwinObserver = FlowSessionDarwinObserver(
|
||||
notificationName: AppGroupConfigDarwin.notificationName
|
||||
) { [weak self] in
|
||||
self?.refreshConfigFromAppGroup()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshConfigFromAppGroup() {
|
||||
persistor.refreshRuntimeFlags(
|
||||
into: state,
|
||||
protectTranslationUntil: translationConfigProtectedUntil,
|
||||
protectPolishScenarioUntil: polishScenarioConfigProtectedUntil
|
||||
)
|
||||
}
|
||||
|
||||
private func refreshFlowSessionState() {
|
||||
persistor.refreshRuntimeFlags(into: state)
|
||||
persistor.refreshRuntimeFlags(
|
||||
into: state,
|
||||
protectTranslationUntil: translationConfigProtectedUntil,
|
||||
protectPolishScenarioUntil: polishScenarioConfigProtectedUntil
|
||||
)
|
||||
consumePendingFlowDeliveryIfNeeded()
|
||||
|
||||
let active = FlowSessionBridge.isSessionActive()
|
||||
@@ -515,8 +595,9 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
debug("received transcript length=\(trimmed.count)")
|
||||
awaitingDictationResult = false
|
||||
stopDictationWatchdog()
|
||||
// Local engine: host app delivers raw ASR transcript; insert as-is.
|
||||
if state.isLocalEngine {
|
||||
|
||||
let runtimeStore = AppGroupStore()
|
||||
guard runtimeStore.shouldRunCloudLLMStep else {
|
||||
textDocumentProxy.insertText(trimmed)
|
||||
state.lastTranscript = ""
|
||||
if let warning = delivery.polishWarning {
|
||||
@@ -527,28 +608,13 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
}
|
||||
return
|
||||
}
|
||||
// Cloud engine: always polish via the configured LLM.
|
||||
|
||||
// Cloud engine, or local engine with cloud polish / translation.
|
||||
state.phase = .processing
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
// v0.2.1: pick the polish mode once at task start so a
|
||||
// mid-flight toggle flip doesn't change the request we
|
||||
// already sent. `isTranslationEffective` no longer gates on
|
||||
// `engineMode == "cloud"` — the row visibility predicate
|
||||
// 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
|
||||
? .translate(targetLocaleId: self.state.translationTargetLocaleId)
|
||||
: .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
|
||||
let polishMode = runtimeStore.polishModeForPipeline
|
||||
let overrideProviderId = runtimeStore.polishProviderIdOverride
|
||||
do {
|
||||
let polished = try await self.polisher.polish(
|
||||
trimmed,
|
||||
@@ -656,9 +722,17 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
private func persistTranslationTargetLocaleId(_ id: String) {
|
||||
let resolved = TranslationLanguageCatalog.resolve(id).id
|
||||
state.translationTargetLocaleId = resolved
|
||||
translationConfigProtectedUntil = Date().addingTimeInterval(2.5)
|
||||
persistor.persist(translationTargetLocaleId: resolved)
|
||||
}
|
||||
|
||||
private func persistPolishScenarioId(_ id: String) {
|
||||
let resolved = PolishScenarioCatalog.resolve(id).id
|
||||
state.polishScenarioId = resolved
|
||||
polishScenarioConfigProtectedUntil = Date().addingTimeInterval(2.5)
|
||||
persistor.persist(polishScenarioId: resolved)
|
||||
}
|
||||
|
||||
// MARK: - Open host app
|
||||
|
||||
private func openHostApp(path: String = "settings") {
|
||||
|
||||
@@ -40,6 +40,8 @@ public struct AppGroupPersistor {
|
||||
// startup; `refreshRuntimeFlags` keeps the chip in sync while
|
||||
// the keyboard stays open.
|
||||
state.translationTargetLocaleId = store.translationTargetLocaleId
|
||||
state.polishScenarioId = store.polishScenarioId
|
||||
state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled
|
||||
// v0.2.0: iOS `SpeechAnalyzer` is always ready; mirror that
|
||||
// into the State flags so downstream consumers see the same
|
||||
// shape they did when the previous Qwen3 stack reported "ready".
|
||||
@@ -75,14 +77,28 @@ public struct AppGroupPersistor {
|
||||
|
||||
/// Lightweight refresh for flags the host app may update while the
|
||||
/// keyboard stays open (model downloads, engine switches).
|
||||
public func refreshRuntimeFlags(into state: KeyboardViewController.State) {
|
||||
///
|
||||
/// When `protectTranslationUntil` is in the future, the translation
|
||||
/// target locale is not overwritten — avoids the 1 Hz poll clobbering
|
||||
/// a chip selection the user just wrote to the App Group.
|
||||
public func refreshRuntimeFlags(
|
||||
into state: KeyboardViewController.State,
|
||||
protectTranslationUntil: Date? = nil,
|
||||
protectPolishScenarioUntil: Date? = nil
|
||||
) {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
let store = AppGroupStore()
|
||||
state.engineMode = store.engineMode
|
||||
state.localASRBackend = store.localASRBackend
|
||||
// v0.2.1 follow-up: same as `load` — only the locale is
|
||||
// persisted, `enabled` is derived.
|
||||
state.translationTargetLocaleId = store.translationTargetLocaleId
|
||||
state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled
|
||||
let shouldProtectTranslation = protectTranslationUntil.map { Date() < $0 } ?? false
|
||||
if !shouldProtectTranslation {
|
||||
state.translationTargetLocaleId = store.translationTargetLocaleId
|
||||
}
|
||||
let shouldProtectScenario = protectPolishScenarioUntil.map { Date() < $0 } ?? false
|
||||
if !shouldProtectScenario {
|
||||
state.polishScenarioId = store.polishScenarioId
|
||||
}
|
||||
// v0.2.0: iOS `SpeechAnalyzer` is always ready. Keep these
|
||||
// toggles here so the keyboard UI doesn't flicker if the host
|
||||
// app briefly clears them while refactoring.
|
||||
@@ -127,4 +143,9 @@ public struct AppGroupPersistor {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
AppGroupStore().setTranslationTargetLocaleId(translationTargetLocaleId)
|
||||
}
|
||||
|
||||
public func persist(polishScenarioId: String) {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
AppGroupStore().setPolishScenarioId(polishScenarioId)
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,17 @@
|
||||
//
|
||||
// Typeless-inspired keyboard surface. The keyboard is laid out in three
|
||||
// vertical bands, but the entire height is reserved for us — we set
|
||||
// `inputView.allowsSelfSizing = true` in the view controller so SwiftUI's
|
||||
// frame is honoured, and we add safe-area insets at the top and bottom so
|
||||
// the system Spotlight / home-indicator chrome never clips our controls.
|
||||
// `KeyboardViewController` drives height on `view` (priority 999) and mirrors
|
||||
// `KeyboardLayoutMetrics.totalHeight` in SwiftUI — see presentation offset
|
||||
// in `applyPresentationHeightOffset()`.
|
||||
//
|
||||
// ┌───────────────────────────────────────────┐
|
||||
// │ [polish] [中] ● ⚙ │ ← top: ~38 pt (+20%)
|
||||
// │ [polish] [中] ⚙ │ ← header band (top)
|
||||
// │ (transcript preview) │
|
||||
// │ │
|
||||
// │ (⌫) ◯ mic (↩) │ ← action row: circular
|
||||
// │ (space) │ flanking buttons
|
||||
// │ ┊ │
|
||||
// │ (⌫) ◯ mic (↩) │ ← action cluster:
|
||||
// │ (space) │ centred below header
|
||||
// │ ┊ │
|
||||
// └───────────────────────────────────────────┘
|
||||
|
||||
import SwiftUI
|
||||
@@ -24,8 +25,33 @@ private enum KeyboardLayoutMetrics {
|
||||
static let sideSpaceBarWidth: CGFloat = 19
|
||||
static let micFlankMinSpacing: CGFloat = 36
|
||||
static let sideActionStackSpacing: CGFloat = 16
|
||||
/// Gap between the top chip row and the transcript / hint line (4 pt → 8 pt, +100%).
|
||||
static let topBarToTranscriptSpacing: CGFloat = Spacing.xs
|
||||
/// Outer inset for delete / return·space from screen edges (8 pt → 24 pt, +200%).
|
||||
static let sideActionHorizontalInset: CGFloat = Spacing.xs * 3
|
||||
|
||||
// MARK: - Content-driven keyboard height (single source of truth)
|
||||
static let outerPaddingTop: CGFloat = 2
|
||||
static let outerPaddingBottom: CGFloat = 6
|
||||
static let topBarHeight: CGFloat = 38
|
||||
static let transcriptLineHeight: CGFloat = 22
|
||||
static let actionClusterHeight: CGFloat = 132
|
||||
/// Fixed breathing room above/below the mic row (not flexible Spacers).
|
||||
static let actionClusterVerticalGap: CGFloat = Spacing.md
|
||||
|
||||
static var headerBandHeight: CGFloat {
|
||||
topBarHeight + topBarToTranscriptSpacing + transcriptLineHeight
|
||||
}
|
||||
|
||||
/// 2 + 68 + 16 + 132 + 16 + 6 = 240 pt
|
||||
static var totalHeight: CGFloat {
|
||||
outerPaddingTop
|
||||
+ headerBandHeight
|
||||
+ actionClusterVerticalGap
|
||||
+ actionClusterHeight
|
||||
+ actionClusterVerticalGap
|
||||
+ outerPaddingBottom
|
||||
}
|
||||
}
|
||||
|
||||
public struct KeyboardRootView: View {
|
||||
@@ -37,11 +63,9 @@ public struct KeyboardRootView: View {
|
||||
self.state = state
|
||||
}
|
||||
|
||||
/// Total keyboard height. We set the same value as a height-anchor
|
||||
/// constraint in the view controller so the host UIInputView picks
|
||||
/// it up.
|
||||
static let totalHeight: CGFloat = 280
|
||||
private static let topBarHeight: CGFloat = 38
|
||||
/// Content-driven keyboard height; mirrored on `UIInputViewController.view`
|
||||
/// in `KeyboardViewController` (see `KeyboardLayoutMetrics.totalHeight`).
|
||||
static let totalHeight: CGFloat = KeyboardLayoutMetrics.totalHeight
|
||||
|
||||
private var palette: ThemePalette {
|
||||
colorScheme == .dark ? Palette.dark : Palette.light
|
||||
@@ -49,14 +73,19 @@ public struct KeyboardRootView: View {
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
topBar
|
||||
.frame(height: Self.topBarHeight)
|
||||
headerBand
|
||||
|
||||
centreArea
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
Color.clear
|
||||
.frame(height: KeyboardLayoutMetrics.actionClusterVerticalGap)
|
||||
|
||||
micActionRow
|
||||
.frame(height: KeyboardLayoutMetrics.actionClusterHeight)
|
||||
|
||||
Color.clear
|
||||
.frame(height: KeyboardLayoutMetrics.actionClusterVerticalGap)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 6)
|
||||
.padding(.top, KeyboardLayoutMetrics.outerPaddingTop)
|
||||
.padding(.bottom, KeyboardLayoutMetrics.outerPaddingBottom)
|
||||
// 透明背景:让系统键盘 chrome 透出,不自行铺色(深浅模式一致)。
|
||||
.background(Color.clear)
|
||||
.frame(height: Self.totalHeight)
|
||||
@@ -64,6 +93,26 @@ public struct KeyboardRootView: View {
|
||||
.environment(\.themePalette, palette)
|
||||
}
|
||||
|
||||
/// Top chip row + transcript / hint line.
|
||||
private var headerBand: some View {
|
||||
VStack(spacing: KeyboardLayoutMetrics.topBarToTranscriptSpacing) {
|
||||
topBar
|
||||
.frame(height: KeyboardLayoutMetrics.topBarHeight)
|
||||
|
||||
TranscriptLine(
|
||||
phase: state.phase,
|
||||
transcript: state.lastTranscript,
|
||||
flowSessionActive: state.flowSessionActive,
|
||||
isLocalEngine: state.isLocalEngine,
|
||||
localModelsReady: state.localModelsReady,
|
||||
localModelsLoaded: state.localModelsLoaded,
|
||||
openSettings: state.openSettings,
|
||||
startFlowSession: state.startFlowSession
|
||||
)
|
||||
.frame(height: KeyboardLayoutMetrics.transcriptLineHeight)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Top bar
|
||||
|
||||
private var topBar: some View {
|
||||
@@ -73,22 +122,20 @@ public struct KeyboardRootView: View {
|
||||
} else {
|
||||
CloudEngineChip()
|
||||
}
|
||||
if state.isPolishScenarioChipVisible {
|
||||
ScenarioChip(state: state)
|
||||
}
|
||||
LocaleChip(localeId: state.localeId) { newId in
|
||||
state.setLocale(newId)
|
||||
}
|
||||
// v0.2.1: translation chip — sits next to the locale picker
|
||||
// and doubles as both the on/off switch and the target-
|
||||
// language picker (Menu pattern matches LocaleChip so the
|
||||
// top bar stays visually consistent).
|
||||
// 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 {
|
||||
// v0.3: always show the translation chip when the active
|
||||
// engine can run the cloud LLM step — off-by-default keeps
|
||||
// the menu reachable so the user can pick a target language
|
||||
// without opening Settings.
|
||||
if state.isTranslationChipVisible {
|
||||
TranslationChip(state: state)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
StatusBadge(phase: state.phase, onDeviceSupported: state.onDeviceSupported)
|
||||
Button(action: state.openSettings) {
|
||||
Image(systemName: "gearshape.fill")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
@@ -103,35 +150,11 @@ public struct KeyboardRootView: View {
|
||||
.padding(.horizontal, Spacing.md)
|
||||
}
|
||||
|
||||
// MARK: - Centre area
|
||||
|
||||
private var centreArea: some View {
|
||||
VStack(spacing: Spacing.xxs) {
|
||||
TranscriptLine(
|
||||
phase: state.phase,
|
||||
transcript: state.lastTranscript,
|
||||
flowSessionActive: state.flowSessionActive,
|
||||
isLocalEngine: state.isLocalEngine,
|
||||
localModelsReady: state.localModelsReady,
|
||||
localModelsLoaded: state.localModelsLoaded,
|
||||
openSettings: state.openSettings,
|
||||
startFlowSession: state.startFlowSession
|
||||
)
|
||||
.frame(height: 22)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
micActionRow
|
||||
.padding(.bottom, Spacing.xs)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
// MARK: - Action cluster
|
||||
|
||||
/// Delete (left), mic (centre), return + space stacked on the right.
|
||||
/// HStack vertical alignment keeps delete, mic centre, and the gap
|
||||
/// between return/space on one horizontal axis.
|
||||
/// Fixed vertical gaps in `body` keep the cluster centred without
|
||||
/// flexible Spacers consuming extra keyboard height.
|
||||
private var micActionRow: some View {
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
CircularToolbarButton(systemName: "delete.left", label: "delete") {
|
||||
@@ -186,19 +209,19 @@ extension KeyboardRootView {
|
||||
#if DEBUG
|
||||
#Preview("Keyboard · Idle") {
|
||||
KeyboardRootView(state: KeyboardViewController.State.previewIdle)
|
||||
.frame(width: 390, height: 280)
|
||||
.frame(width: 390, height: KeyboardRootView.totalHeight)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
#Preview("Keyboard · Recording") {
|
||||
KeyboardRootView(state: KeyboardViewController.State.previewRecording)
|
||||
.frame(width: 390, height: 280)
|
||||
.frame(width: 390, height: KeyboardRootView.totalHeight)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
#Preview("Keyboard · Processing") {
|
||||
KeyboardRootView(state: KeyboardViewController.State.previewProcessing)
|
||||
.frame(width: 390, height: 280)
|
||||
.frame(width: 390, height: KeyboardRootView.totalHeight)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
#endif
|
||||
@@ -367,62 +390,6 @@ private struct CircularToolbarButton: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Status badge
|
||||
|
||||
private struct StatusBadge: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
let phase: KeyboardViewController.State.Phase
|
||||
/// Reflects whether the active ASR session is on-device. We surface
|
||||
/// a small ⚠️ during recording so the user knows their audio is
|
||||
/// going to the cloud for this locale (and so devs catch it during
|
||||
/// QA without staring at the Xcode console).
|
||||
let onDeviceSupported: Bool
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch phase {
|
||||
case .idle:
|
||||
EmptyView()
|
||||
case .requestingPermissions:
|
||||
EmptyView()
|
||||
case .recording:
|
||||
if onDeviceSupported {
|
||||
dot(color: palette.recordRed, labelKey: "keyboard.status.rec")
|
||||
} else {
|
||||
dot(color: palette.warning, labelKey: "keyboard.status.recWarning", showWarning: true)
|
||||
}
|
||||
case .processing:
|
||||
dot(color: palette.accent, labelKey: "keyboard.status.processing")
|
||||
case .error:
|
||||
dot(color: palette.warning, labelKey: "keyboard.status.error")
|
||||
case .denied:
|
||||
dot(color: palette.warning, labelKey: "keyboard.status.error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dot(color: Color, labelKey: String, showWarning: Bool = false) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 6, height: 6)
|
||||
if showWarning {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(palette.warning)
|
||||
}
|
||||
Text(ExtL10n.string(labelKey))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, Spacing.xs)
|
||||
.padding(.vertical, 4)
|
||||
.background(palette.surface, in: Capsule())
|
||||
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cloud engine chip (cloud always ASR + LLM polish)
|
||||
|
||||
private struct CloudEngineChip: View {
|
||||
|
||||
@@ -42,11 +42,20 @@ struct RecordButton: View {
|
||||
return remainingSeconds <= 10
|
||||
}
|
||||
|
||||
/// Decorative rings are sized to stay inside the 132 pt frame applied
|
||||
/// by `KeyboardRootView` so glow / breath animations are not clipped.
|
||||
private enum Layout {
|
||||
static let disc: CGFloat = 104
|
||||
static let outerRing: CGFloat = 112
|
||||
static let breathRing: CGFloat = 108
|
||||
static let glow: CGFloat = 128
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(palette.recordRed.opacity(isUrgent ? 0.55 : 0.35), lineWidth: isUrgent ? 3 : 2)
|
||||
.frame(width: 150, height: 150)
|
||||
.frame(width: Layout.breathRing, height: Layout.breathRing)
|
||||
.scaleEffect(breath ? 1.18 : 0.95)
|
||||
.opacity(phase == .recording ? 1 : 0)
|
||||
.animation(Motion.breath, value: breath)
|
||||
@@ -60,7 +69,7 @@ struct RecordButton: View {
|
||||
endRadius: 100
|
||||
)
|
||||
)
|
||||
.frame(width: 200, height: 200)
|
||||
.frame(width: Layout.glow, height: Layout.glow)
|
||||
.opacity(phase == .recording ? 0.4 + level * 0.6 : 0)
|
||||
.blur(radius: 18)
|
||||
.animation(Motion.soft, value: phase)
|
||||
@@ -71,7 +80,7 @@ struct RecordButton: View {
|
||||
Color.white.opacity(phase == .idle ? 0.08 : 0.12),
|
||||
lineWidth: 0.5
|
||||
)
|
||||
.frame(width: 140, height: 140)
|
||||
.frame(width: Layout.outerRing, height: Layout.outerRing)
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
@@ -115,7 +124,7 @@ struct RecordButton: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 120, height: 120)
|
||||
.frame(width: Layout.disc, height: Layout.disc)
|
||||
.animation(Motion.soft, value: phase)
|
||||
.animation(Motion.soft, value: remainingSeconds)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// ScenarioChip.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Compact chip on the keyboard top bar for quick polish scenario
|
||||
// switching. Same Menu pattern as `TranslationChip`.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct ScenarioChip: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
@ObservedObject var state: KeyboardViewController.State
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
ForEach(PolishScenarioCatalog.all) { scenario in
|
||||
Button {
|
||||
state.setPolishScenarioId(scenario.id)
|
||||
} label: {
|
||||
if scenario.id == state.polishScenarioId {
|
||||
Label(displayLabel(for: scenario), systemImage: "checkmark")
|
||||
} else {
|
||||
Text(displayLabel(for: scenario))
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
label
|
||||
}
|
||||
.menuStyle(.button)
|
||||
.accessibilityLabel(ExtL10n.text("keyboard.scenario.a11y"))
|
||||
.accessibilityHint(ExtL10n.text("keyboard.scenario.a11yHint"))
|
||||
}
|
||||
|
||||
private var label: some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "text.bubble")
|
||||
Text(chipText)
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
}
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.padding(.horizontal, Spacing.xs + 2)
|
||||
.padding(.vertical, 6)
|
||||
.frame(minHeight: 28)
|
||||
.background(palette.surfaceElevated, in: Capsule())
|
||||
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
|
||||
private var chipText: String {
|
||||
PolishScenarioCatalog.chipLabel(for: state.polishScenarioId)
|
||||
}
|
||||
|
||||
private func displayLabel(for scenario: PolishScenario) -> String {
|
||||
PolishScenarioCatalog.displayName(for: scenario.id)
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
// off / on, with the same accent treatment either way.
|
||||
//
|
||||
// Visual states:
|
||||
// • off → dim outline, "翻译" label
|
||||
// • off → dim outline, "翻译" chip label (menu first row = "不翻译")
|
||||
// • on (any engine) → accent fill, "→ EN" / "→ 日本語" style label
|
||||
//
|
||||
// Stays in the same visual family as `CloudEngineChip` / `LocaleChip`
|
||||
@@ -88,14 +88,14 @@ struct TranslationChip: View {
|
||||
|
||||
private func displayLabel(for language: TranslationLanguage) -> String {
|
||||
if language.id == TranslationLanguageCatalog.offLocaleId {
|
||||
return ExtL10n.string("keyboard.translation.off")
|
||||
return ExtL10n.string("keyboard.translation.offMenu")
|
||||
}
|
||||
return language.nativeName
|
||||
}
|
||||
|
||||
private func chipLabel(target: TranslationLanguage, enabled: Bool) -> String {
|
||||
if !enabled {
|
||||
return ExtL10n.string("keyboard.translation.off")
|
||||
return ExtL10n.string("keyboard.translation.chip")
|
||||
}
|
||||
// Short form: "→EN" / "→日" style. Falls back to the prompt
|
||||
// language name for languages without a chip-style abbreviation
|
||||
|
||||
@@ -176,12 +176,16 @@
|
||||
"locale.chip.ja-JP" = "日";
|
||||
"locale.chip.ko-KR" = "韩";
|
||||
|
||||
/* Translation chip (v0.2.1) */
|
||||
"keyboard.translation.off" = "Translate";
|
||||
/* Translation chip (v0.3) */
|
||||
"keyboard.translation.chip" = "Translate";
|
||||
"keyboard.translation.offMenu" = "Don't translate";
|
||||
"keyboard.translation.off" = "Don't translate";
|
||||
"keyboard.translation.enable" = "Enable translation";
|
||||
"keyboard.translation.disable" = "Disable translation";
|
||||
"keyboard.translation.a11y" = "Translation";
|
||||
"keyboard.translation.a11yHint" = "Toggle translation or change the target language.";
|
||||
"keyboard.scenario.a11y" = "Polish scenario";
|
||||
"keyboard.scenario.a11yHint" = "Choose how dictation is polished.";
|
||||
|
||||
/* Mode chip labels (used in both ext + preview stub) */
|
||||
"mode.off" = "Off";
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。";
|
||||
"settings.engine.cloud.title" = "云端识别与润色";
|
||||
"settings.engine.cloud.subtitle" = "ASR 转录 + LLM 润色,需要 API Key。";
|
||||
"settings.provider.title" = "提供商";
|
||||
"settings.provider.title" = "云端引擎";
|
||||
"settings.provider.subtitle" = "选择 LLM 提供商。";
|
||||
"settings.api.title" = "接口";
|
||||
"settings.language.title" = "语言";
|
||||
@@ -176,12 +176,16 @@
|
||||
"locale.chip.ja-JP" = "日";
|
||||
"locale.chip.ko-KR" = "韩";
|
||||
|
||||
/* 翻译 chip (v0.2.1) */
|
||||
"keyboard.translation.off" = "翻译";
|
||||
/* Translation chip (v0.3) */
|
||||
"keyboard.translation.chip" = "翻译";
|
||||
"keyboard.translation.offMenu" = "不翻译";
|
||||
"keyboard.translation.off" = "不翻译";
|
||||
"keyboard.translation.enable" = "开启翻译";
|
||||
"keyboard.translation.disable" = "关闭翻译";
|
||||
"keyboard.translation.a11y" = "翻译";
|
||||
"keyboard.translation.a11yHint" = "切换翻译开关或修改目标语言。";
|
||||
"keyboard.scenario.a11y" = "润色场景";
|
||||
"keyboard.scenario.a11yHint" = "选择润色风格或使用场景。";
|
||||
|
||||
/* Mode chip labels */
|
||||
"mode.off" = "关闭";
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// PolishScenario.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Curated polish scenarios the user picks instead of editing a raw
|
||||
// system prompt. Display names live in Shared.strings; prompt bodies
|
||||
// are built by `ScenarioPrompt.make`.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct PolishScenario: Identifiable, Hashable, Sendable {
|
||||
public let id: String
|
||||
/// Shared.strings key for the picker label.
|
||||
public let labelKey: String
|
||||
/// Shared.strings key for the compact keyboard chip label.
|
||||
public let chipLabelKey: String
|
||||
|
||||
public init(id: String, labelKey: String, chipLabelKey: String) {
|
||||
self.id = id
|
||||
self.labelKey = labelKey
|
||||
self.chipLabelKey = chipLabelKey
|
||||
}
|
||||
}
|
||||
|
||||
public enum PolishScenarioCatalog {
|
||||
public static let defaultId = "daily_chat"
|
||||
public static let customId = "custom"
|
||||
|
||||
/// Order matters — picker / chip render top-to-bottom.
|
||||
public static let all: [PolishScenario] = [
|
||||
PolishScenario(id: "daily_chat", labelKey: "polishScenario.daily_chat", chipLabelKey: "polishScenario.chip.daily_chat"),
|
||||
PolishScenario(id: "social_lifestyle", labelKey: "polishScenario.social_lifestyle", chipLabelKey: "polishScenario.chip.social_lifestyle"),
|
||||
PolishScenario(id: "social_short", labelKey: "polishScenario.social_short", chipLabelKey: "polishScenario.chip.social_short"),
|
||||
PolishScenario(id: "goofy", labelKey: "polishScenario.goofy", chipLabelKey: "polishScenario.chip.goofy"),
|
||||
PolishScenario(id: "work", labelKey: "polishScenario.work", chipLabelKey: "polishScenario.chip.work"),
|
||||
PolishScenario(id: "document", labelKey: "polishScenario.document", chipLabelKey: "polishScenario.chip.document"),
|
||||
PolishScenario(id: "todo", labelKey: "polishScenario.todo", chipLabelKey: "polishScenario.chip.todo"),
|
||||
PolishScenario(id: customId, labelKey: "polishScenario.custom", chipLabelKey: "polishScenario.chip.custom"),
|
||||
]
|
||||
|
||||
public static func isCustom(_ id: String) -> Bool {
|
||||
id == customId
|
||||
}
|
||||
|
||||
public static func resolve(_ id: String) -> PolishScenario {
|
||||
all.first { $0.id == id } ?? all.first { $0.id == defaultId } ?? all[0]
|
||||
}
|
||||
|
||||
public static func displayName(for id: String, language: AppUILanguage? = nil) -> String {
|
||||
SharedL10n.string(resolve(id).labelKey, language: language)
|
||||
}
|
||||
|
||||
public static func chipLabel(for id: String, language: AppUILanguage? = nil) -> String {
|
||||
SharedL10n.string(resolve(id).chipLabelKey, language: language)
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
// who upgraded from a build that wrote it don't see a flash of
|
||||
// "on" state during init, but new writes never touch the key.
|
||||
static let translationTargetLocaleId = "config.translationTargetLocaleId"
|
||||
static let polishScenarioId = "config.polishScenarioId"
|
||||
}
|
||||
|
||||
@Published public var providerId: String {
|
||||
@@ -121,7 +122,10 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
/// box. Users opt in from Settings when the iOS ASR output isn't
|
||||
/// strong enough (noisy far-field audio, dialectal Chinese, etc.).
|
||||
@Published public var localModeCloudPolishEnabled: Bool {
|
||||
didSet { defaults.set(localModeCloudPolishEnabled, forKey: Key.localModeCloudPolishEnabled) }
|
||||
didSet {
|
||||
defaults.set(localModeCloudPolishEnabled, forKey: Key.localModeCloudPolishEnabled)
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
}
|
||||
/// Host-app UI language. Also mirrored to the App Group for the keyboard extension.
|
||||
@Published public var uiLanguage: AppUILanguage {
|
||||
@@ -146,25 +150,46 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
/// extension can honour it (and so the chip on the keyboard reflects
|
||||
/// the user's choice without a host-app round-trip).
|
||||
@Published public var translationTargetLocaleId: String {
|
||||
didSet { defaults.set(translationTargetLocaleId, forKey: Key.translationTargetLocaleId) }
|
||||
didSet {
|
||||
defaults.set(translationTargetLocaleId, forKey: Key.translationTargetLocaleId)
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
}
|
||||
/// Selected polish scenario preset (e.g. `daily_chat`, `work`) or
|
||||
/// `custom` when the user edits the raw system prompt.
|
||||
@Published public var polishScenarioId: String {
|
||||
didSet {
|
||||
defaults.set(polishScenarioId, forKey: Key.polishScenarioId)
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
}
|
||||
|
||||
/// v0.2.1 follow-up: with the local engine's translate-and-polish
|
||||
/// path now real (see `localModeProviderId`), `translationEnabled`
|
||||
/// alone is enough to decide whether the pipeline should translate.
|
||||
/// Row visibility (`isTranslationRowVisible`) already gates the UI
|
||||
/// on engines that can actually run the step, so we don't need to
|
||||
/// re-check `engineMode` here.
|
||||
/// Whether the pipeline should run translate-and-polish (not just
|
||||
/// polish). Cloud engine: any selected target locale. Local engine:
|
||||
/// only when cloud polish is also enabled.
|
||||
public var isTranslationEffective: Bool {
|
||||
translationEnabled
|
||||
guard translationEnabled else { return false }
|
||||
if isLocalEngine { return localModeCloudPolishEnabled }
|
||||
return true
|
||||
}
|
||||
|
||||
/// v0.2.1 follow-up: row visibility predicate. Both engines can
|
||||
/// now run the cloud translate-and-polish step (the local engine
|
||||
/// routes through DeepSeek via `localModeProviderId`), so the row
|
||||
/// is shown whenever an engine mode is selected.
|
||||
/// Translation picker visibility. Cloud engine: always. Local engine:
|
||||
/// only when "Cloud polish after ASR" is on — translation is a
|
||||
/// sub-step of that cloud LLM pass, not a standalone feature.
|
||||
public var isTranslationRowVisible: Bool {
|
||||
engineMode == "local" || engineMode == "cloud"
|
||||
if engineMode == "cloud" { return true }
|
||||
return isLocalEngine && localModeCloudPolishEnabled
|
||||
}
|
||||
|
||||
/// Polish scenario picker visibility — same gate as translation:
|
||||
/// only when the pipeline can run a cloud LLM step.
|
||||
public var isPolishScenarioRowVisible: Bool {
|
||||
if engineMode == "cloud" { return true }
|
||||
return isLocalEngine && localModeCloudPolishEnabled
|
||||
}
|
||||
|
||||
public var isCustomPolishScenario: Bool {
|
||||
PolishScenarioCatalog.isCustom(polishScenarioId)
|
||||
}
|
||||
|
||||
public var isConfigured: Bool {
|
||||
@@ -263,6 +288,17 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
// conservative default that matches the picker / chip UX).
|
||||
self.translationTargetLocaleId = resolvedDefaults.string(forKey: Key.translationTargetLocaleId)
|
||||
?? TranslationLanguageCatalog.offLocaleId
|
||||
if let storedScenario = resolvedDefaults.string(forKey: Key.polishScenarioId) {
|
||||
self.polishScenarioId = PolishScenarioCatalog.resolve(storedScenario).id
|
||||
} else {
|
||||
let savedPrompt = resolvedDefaults.string(forKey: Key.systemPrompt)
|
||||
?? AppGroupStore.defaultSystemPrompt(for: pid)
|
||||
if savedPrompt != AppGroupStore.defaultSystemPrompt(for: pid) {
|
||||
self.polishScenarioId = PolishScenarioCatalog.customId
|
||||
} else {
|
||||
self.polishScenarioId = PolishScenarioCatalog.defaultId
|
||||
}
|
||||
}
|
||||
|
||||
// Cloud no longer exposes off/transcribe; migrate legacy values.
|
||||
if self.engineMode == "cloud", self.modeId != "polish" {
|
||||
@@ -313,6 +349,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
apiKey = ""
|
||||
model = preset.defaultModel
|
||||
systemPrompt = AppGroupStore.defaultSystemPrompt(for: "openai")
|
||||
polishScenarioId = PolishScenarioCatalog.defaultId
|
||||
hasAcknowledgedCloudSharing = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// AppGroupConfigDarwin.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Cross-process Darwin notification when App Group config changes
|
||||
// (translation target, cloud-polish toggle, etc.). Lets the host app
|
||||
// and keyboard extension pick up writes without waiting on the 1 Hz poll.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum AppGroupConfigDarwin {
|
||||
public static let notificationName = "com.osgkeyboard.config.changed"
|
||||
|
||||
public static func postConfigChanged() {
|
||||
CFNotificationCenterPostNotification(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
CFNotificationName(notificationName as CFString),
|
||||
nil,
|
||||
nil,
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
// the `translationEnabled` Bool accessor below is kept as a
|
||||
// computed shim for source compatibility.
|
||||
static let translationTargetLocaleId = "config.translationTargetLocaleId"
|
||||
static let polishScenarioId = "config.polishScenarioId"
|
||||
}
|
||||
|
||||
// MARK: - Reads
|
||||
@@ -127,6 +128,11 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
?? TranslationLanguageCatalog.offLocaleId
|
||||
}
|
||||
|
||||
public var polishScenarioId: String {
|
||||
let stored = defaults.string(forKey: Key.polishScenarioId)
|
||||
return PolishScenarioCatalog.resolve(stored ?? PolishScenarioCatalog.defaultId).id
|
||||
}
|
||||
|
||||
// MARK: - Writes
|
||||
|
||||
public func setModeId(_ id: String) {
|
||||
@@ -169,6 +175,70 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
/// round-trip.
|
||||
public func setTranslationTargetLocaleId(_ id: String) {
|
||||
defaults.set(id, forKey: Key.translationTargetLocaleId)
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
public func setPolishScenarioId(_ id: String) {
|
||||
let resolved = PolishScenarioCatalog.resolve(id).id
|
||||
defaults.set(resolved, forKey: Key.polishScenarioId)
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
/// Whether ASR output should be sent through the cloud LLM step.
|
||||
/// Cloud engine: always. Local engine: only when cloud polish is
|
||||
/// enabled (translation is a sub-option of that step).
|
||||
public var shouldRunCloudLLMStep: Bool {
|
||||
if engineMode == "cloud" { return true }
|
||||
return localModeCloudPolishEnabled
|
||||
}
|
||||
|
||||
/// Whether translate-and-polish should run (vs polish-only).
|
||||
public var isTranslationEffective: Bool {
|
||||
guard translationEnabled else { return false }
|
||||
if engineMode == "local" { return localModeCloudPolishEnabled }
|
||||
return true
|
||||
}
|
||||
|
||||
/// Whether the keyboard top-bar translation chip should render.
|
||||
/// Cloud engine: always. Local engine: when cloud polish is enabled
|
||||
/// (translation is a sub-option of that LLM step). Independent of
|
||||
/// whether a target locale is currently selected — the chip stays
|
||||
/// visible so the user can pick "不翻译" or a language in-place.
|
||||
public var isTranslationChipVisible: Bool {
|
||||
if engineMode == "cloud" { return true }
|
||||
return localModeCloudPolishEnabled
|
||||
}
|
||||
|
||||
/// Whether the keyboard top-bar scenario chip should render.
|
||||
public var isPolishScenarioChipVisible: Bool {
|
||||
if engineMode == "cloud" { return true }
|
||||
return localModeCloudPolishEnabled
|
||||
}
|
||||
|
||||
/// System prompt for the polish pipeline honoring scenario selection.
|
||||
public func resolvedPolishSystemPrompt(providerId: String? = nil) -> String {
|
||||
if PolishScenarioCatalog.isCustom(polishScenarioId) {
|
||||
return systemPrompt
|
||||
}
|
||||
let pid = providerId ?? self.providerId
|
||||
return ScenarioPrompt.make(
|
||||
scenarioId: polishScenarioId,
|
||||
providerId: pid,
|
||||
uiLanguage: uiLanguage
|
||||
)
|
||||
}
|
||||
|
||||
/// Polish vs translate-and-polish for the active pipeline.
|
||||
public var polishModeForPipeline: PolishingService.PolishMode {
|
||||
isTranslationEffective
|
||||
? .translate(targetLocaleId: translationTargetLocaleId)
|
||||
: .polish
|
||||
}
|
||||
|
||||
/// Local engine pins the LLM step to DeepSeek; cloud uses the
|
||||
/// user's configured provider.
|
||||
public var polishProviderIdOverride: String? {
|
||||
engineMode == "local" ? "deepseek" : nil
|
||||
}
|
||||
|
||||
// MARK: - Client
|
||||
|
||||
@@ -20,7 +20,7 @@ public enum FlowSessionDarwin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Observes Flow session Darwin notifications on a background thread; invokes
|
||||
/// Observes Darwin notifications on a background thread; invokes
|
||||
/// `handler` on the main actor.
|
||||
public final class FlowSessionDarwinObserver {
|
||||
private final class Box: @unchecked Sendable {
|
||||
@@ -30,11 +30,16 @@ public final class FlowSessionDarwinObserver {
|
||||
|
||||
private let box: Box
|
||||
private let token: UnsafeMutableRawPointer
|
||||
private let notificationName: CFString
|
||||
|
||||
public init(handler: @escaping @MainActor () -> Void) {
|
||||
public init(
|
||||
notificationName: String = FlowSessionDarwin.notificationName,
|
||||
handler: @escaping @MainActor () -> Void
|
||||
) {
|
||||
let box = Box(handler: handler)
|
||||
self.box = box
|
||||
self.token = Unmanaged.passRetained(box).toOpaque()
|
||||
self.notificationName = notificationName as CFString
|
||||
|
||||
CFNotificationCenterAddObserver(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
@@ -44,7 +49,7 @@ public final class FlowSessionDarwinObserver {
|
||||
let box = Unmanaged<Box>.fromOpaque(observer).takeUnretainedValue()
|
||||
Task { @MainActor in box.handler() }
|
||||
},
|
||||
FlowSessionDarwin.notificationName as CFString,
|
||||
self.notificationName,
|
||||
nil,
|
||||
.deliverImmediately
|
||||
)
|
||||
@@ -54,7 +59,7 @@ public final class FlowSessionDarwinObserver {
|
||||
CFNotificationCenterRemoveObserver(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
token,
|
||||
CFNotificationName(FlowSessionDarwin.notificationName as CFString),
|
||||
CFNotificationName(notificationName),
|
||||
nil
|
||||
)
|
||||
Unmanaged<Box>.fromOpaque(token).release()
|
||||
|
||||
@@ -98,14 +98,29 @@ public final class KeyboardState: ObservableObject {
|
||||
/// Defaults to `offLocaleId` so the keyboard boots in the "off"
|
||||
/// state on first install.
|
||||
@Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId
|
||||
/// v0.2.1: effective predicate — mirrors `ProviderConfig`.
|
||||
/// v0.2.1 follow-up: no longer gates on `engineMode == "cloud"`
|
||||
/// because the local engine's translate-and-polish step now runs
|
||||
/// (routed through DeepSeek). Row visibility (`isTranslationRowVisible`
|
||||
/// on `ProviderConfig`) keeps the picker honest, so the keyboard
|
||||
/// can rely on `translationEnabled` alone here.
|
||||
/// Selected polish scenario mirrored from App Group.
|
||||
@Published public var polishScenarioId: String = PolishScenarioCatalog.defaultId
|
||||
/// v0.2.0: mirrored from App Group — local engine runs the cloud
|
||||
/// LLM step only when this is `true`.
|
||||
@Published public var localModeCloudPolishEnabled: Bool = false
|
||||
/// Whether translate-and-polish is actually armed for the current
|
||||
/// engine (local requires cloud polish + a target locale).
|
||||
public var isTranslationEffective: Bool {
|
||||
translationEnabled
|
||||
guard translationEnabled else { return false }
|
||||
if isLocalEngine { return localModeCloudPolishEnabled }
|
||||
return true
|
||||
}
|
||||
|
||||
/// Whether the keyboard top-bar translation chip should render.
|
||||
public var isTranslationChipVisible: Bool {
|
||||
if isLocalEngine { return localModeCloudPolishEnabled }
|
||||
return true
|
||||
}
|
||||
|
||||
/// Whether the keyboard top-bar polish scenario chip should render.
|
||||
public var isPolishScenarioChipVisible: Bool {
|
||||
if isLocalEngine { return localModeCloudPolishEnabled }
|
||||
return true
|
||||
}
|
||||
|
||||
/// Convenience shorthand used by the pipeline and views.
|
||||
@@ -125,6 +140,7 @@ public final class KeyboardState: ObservableObject {
|
||||
/// is derived from the locale id, so there's no separate toggle to
|
||||
/// persist. Wired in `KeyboardViewController.installStateActions`.
|
||||
public var setTranslationTargetLocaleId: (String) -> Void = { _ in }
|
||||
public var setPolishScenarioId: (String) -> Void = { _ in }
|
||||
public var insertNewline: () -> Void = {}
|
||||
public var insertSpace: () -> Void = {}
|
||||
public var deleteBackward: () -> Void = {}
|
||||
|
||||
@@ -8,14 +8,12 @@
|
||||
// Engine matrix:
|
||||
// - `engineMode == "cloud"` → always polish (cloud engine's whole point).
|
||||
// - `engineMode == "local"`,
|
||||
// `localModeCloudPolishEnabled == false` → ASR-only, return raw.
|
||||
// cloud polish disabled → ASR-only, return raw.
|
||||
// - `engineMode == "local"`,
|
||||
// `localModeCloudPolishEnabled == true` → polish via the user's LLM
|
||||
// (DeepSeek by default). The local engine gains stronger accuracy on
|
||||
// noisy / dialectal Chinese at the cost of one cloud round-trip.
|
||||
// If the user hasn't entered an API key the call falls back to the
|
||||
// raw transcript and surfaces a warning so the keyboard can show
|
||||
// the "fill in your key" hint.
|
||||
// cloud polish enabled → DeepSeek LLM step (polish or translate).
|
||||
// Translation uses `.translate` + `TranslationPrompt`; polish uses
|
||||
// the default system prompt. Missing preconfigured DeepSeek key
|
||||
// throws `missingAPIKey` and callers deliver raw + warning.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -24,10 +22,8 @@ public actor PolishingService {
|
||||
public enum PolishError: Error, Equatable {
|
||||
case noTranscript
|
||||
case timeout
|
||||
/// v0.2.0: local engine + cloud-polish-on, but the user hasn't
|
||||
/// saved an API key in the Keychain. Caller surfaces an Alert
|
||||
/// telling them to fill it in; we deliver the raw transcript
|
||||
/// so no data is lost.
|
||||
/// Local engine DeepSeek step: `PreconfiguredKeys.deepseek` is
|
||||
/// still the repo placeholder, or cloud engine Keychain is empty.
|
||||
case missingAPIKey
|
||||
}
|
||||
|
||||
@@ -74,16 +70,10 @@ public actor PolishingService {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { throw PolishError.noTranscript }
|
||||
|
||||
// Local engine: ASR-only unless the user opted into cloud
|
||||
// polish via `localModeCloudPolishEnabled`. The cloud polish
|
||||
// path still requires an API key; if the Keychain is empty we
|
||||
// fall back to the raw transcript and throw `missingAPIKey`
|
||||
// so the UI can surface the "fill in your key" hint.
|
||||
// Local engine: ASR-only unless cloud polish is enabled
|
||||
// (translation is a sub-option of that LLM step).
|
||||
if store.engineMode == "local" {
|
||||
guard store.localModeCloudPolishEnabled else { return trimmed }
|
||||
guard !store.apiKey.isEmpty else {
|
||||
throw PolishError.missingAPIKey
|
||||
}
|
||||
guard store.shouldRunCloudLLMStep else { return trimmed }
|
||||
return try await polishRemote(
|
||||
trimmed,
|
||||
mode: mode,
|
||||
@@ -117,12 +107,11 @@ public actor PolishingService {
|
||||
client = injectedClient
|
||||
} else {
|
||||
let preset = LLMProvider.provider(id: effectiveProviderId)
|
||||
let baseURL = store.baseURL.isEmpty ? preset.defaultBaseURL : store.baseURL
|
||||
// Pre-existing typo fix: the user-overridden `store.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 (baseURL, model) = Self.resolveLLMEndpoint(
|
||||
store: store,
|
||||
preset: preset,
|
||||
providerIdOverride: providerIdOverride
|
||||
)
|
||||
let apiKey: String
|
||||
if effectiveProviderId == "deepseek" {
|
||||
let preconfigured = PreconfiguredKeys.deepseek
|
||||
@@ -138,7 +127,11 @@ public actor PolishingService {
|
||||
}
|
||||
client = OpenAICompatibleClient(baseURL: baseURL, apiKey: apiKey, model: model)
|
||||
}
|
||||
let prompt = resolvedSystemPrompt(for: mode, override: systemPrompt)
|
||||
let prompt = resolvedSystemPrompt(
|
||||
for: mode,
|
||||
override: systemPrompt,
|
||||
providerId: effectiveProviderId
|
||||
)
|
||||
let budget = effectiveTimeout(for: trimmed)
|
||||
|
||||
return try await withThrowingTaskGroup(of: String.self) { group in
|
||||
@@ -161,17 +154,26 @@ public actor PolishingService {
|
||||
/// existing `store.systemPrompt` behaviour so every other call site
|
||||
/// is byte-identical to before. An explicit `override` wins over
|
||||
/// both paths so callers (and tests) can pin a specific prompt.
|
||||
private func resolvedSystemPrompt(for mode: PolishMode, override: String? = nil) -> String {
|
||||
private func resolvedSystemPrompt(
|
||||
for mode: PolishMode,
|
||||
override: String? = nil,
|
||||
providerId: String? = nil
|
||||
) -> String {
|
||||
if let override, !override.isEmpty {
|
||||
return override
|
||||
}
|
||||
switch mode {
|
||||
case .polish:
|
||||
return store.systemPrompt
|
||||
return store.resolvedPolishSystemPrompt(providerId: providerId)
|
||||
case .translate(let targetLocaleId):
|
||||
let target = TranslationLanguageCatalog.resolve(targetLocaleId)
|
||||
let pid = store.providerId
|
||||
return TranslationPrompt.make(target: target, providerId: pid)
|
||||
let pid = providerId ?? store.providerId
|
||||
return TranslationPrompt.make(
|
||||
target: target,
|
||||
providerId: pid,
|
||||
scenarioId: store.polishScenarioId,
|
||||
uiLanguage: store.uiLanguage
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,4 +182,41 @@ public actor PolishingService {
|
||||
let scaled = timeout + (Double(text.count) / 200.0) * 2.0
|
||||
return min(max(scaled, timeout), 120)
|
||||
}
|
||||
|
||||
/// Picks base URL + model for one remote polish call.
|
||||
///
|
||||
/// When `providerIdOverride` is set (local engine pins DeepSeek),
|
||||
/// always use that preset's defaults so cloud-engine settings
|
||||
/// (e.g. Qwen base URL saved while testing cloud mode) are not
|
||||
/// mixed with the pinned provider's API key. Cloud engine passes
|
||||
/// `nil` and keeps honoring `store.baseURL` / `store.model`.
|
||||
internal static func resolveLLMEndpoint(
|
||||
store: AppGroupStore,
|
||||
preset: LLMProvider,
|
||||
providerIdOverride: String?
|
||||
) -> (baseURL: String, model: String) {
|
||||
if providerIdOverride != nil {
|
||||
return (preset.defaultBaseURL, preset.defaultModel)
|
||||
}
|
||||
let baseURL = store.baseURL.isEmpty ? preset.defaultBaseURL : store.baseURL
|
||||
// Pre-existing typo fix: the user-overridden `store.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
|
||||
return (baseURL, model)
|
||||
}
|
||||
}
|
||||
|
||||
extension PolishingService.PolishError: LocalizedError {
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .noTranscript:
|
||||
return "No transcript to polish."
|
||||
case .timeout:
|
||||
return "LLM polish timed out."
|
||||
case .missingAPIKey:
|
||||
return "Missing API key (local: set PreconfiguredKeys.deepseek; cloud: Settings API key)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ public enum PreconfiguredKeys {
|
||||
/// Preconfigured DeepSeek API key. Replace `placeholder` with a
|
||||
/// real key in `Sources/.../PreconfiguredKeys.swift` before
|
||||
/// distributing a build.
|
||||
public static let deepseek: String = placeholder
|
||||
public static let deepseek: String = "REMOVED_LEAKED_DEEPSEEK_KEY"
|
||||
|
||||
#if DEBUG
|
||||
/// Forces a lazy init at app launch in DEBUG builds so the assert
|
||||
@@ -47,4 +47,4 @@ public enum PreconfiguredKeys {
|
||||
_ = isDeepseekConfigured
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// ScenarioPrompt.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Builds the system prompt for a selected polish scenario. Output
|
||||
// format rules come from `ScenarioStyleDirective` (shared with
|
||||
// `TranslationPrompt`).
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum ScenarioPrompt {
|
||||
|
||||
public static func make(
|
||||
scenarioId: String,
|
||||
providerId: String,
|
||||
uiLanguage: AppUILanguage? = nil
|
||||
) -> String {
|
||||
let isChineseNative = Self.isChineseNativeProvider(providerId)
|
||||
let directive = ScenarioStyleDirective.make(
|
||||
scenarioId: scenarioId,
|
||||
providerId: providerId,
|
||||
uiLanguage: uiLanguage
|
||||
)
|
||||
return isChineseNative
|
||||
? """
|
||||
\(chineseBase)
|
||||
\(directive)
|
||||
"""
|
||||
: """
|
||||
\(englishBase)
|
||||
\(directive)
|
||||
"""
|
||||
}
|
||||
|
||||
private static func isChineseNativeProvider(_ providerId: String) -> Bool {
|
||||
["zhipu", "moonshot", "qwen", "deepseek"].contains(providerId)
|
||||
}
|
||||
|
||||
private static let chineseBase = """
|
||||
你是一位语音输入润色助手。用户用 ASR 转写了一段可能含噪声的口述。
|
||||
硬性要求:
|
||||
1) 保留原意,不编造事实;保持输入语言。
|
||||
2) 修复 ASR 噪声(同音错字、漏字、断句错乱)。
|
||||
3) 去掉无意义的口头禅(嗯、啊、那个)。
|
||||
4) 简洁;若下方场景未要求列表/分段,不超出原长 1.5 倍。
|
||||
5) 若场景格式要求列表或分段,允许按格式组织;总长度不超过原长 2 倍。
|
||||
6) 只输出润色后的正文,不要解释、不要加引号。
|
||||
"""
|
||||
|
||||
private static let englishBase = """
|
||||
You are a voice-input polishing assistant. The user spoke informally and the transcript may contain ASR noise.
|
||||
Hard rules:
|
||||
1) Preserve meaning; do not invent facts; keep the input language.
|
||||
2) Fix ASR noise (homophone errors, missing characters, broken segmentation).
|
||||
3) Drop filler words (um, uh, like).
|
||||
4) Stay concise; if the scenario below does not require lists/sections, do not exceed 1.5x the spoken length.
|
||||
5) If the scenario requires bullets or paragraph breaks, use that layout; total length may be up to 2x when listing.
|
||||
6) Output ONLY the polished text. No quotes, no explanation, no preamble.
|
||||
"""
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// ScenarioStyleDirective.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Shared output-format rules for each polish scenario. Injected into
|
||||
// both `ScenarioPrompt` (polish-only) and `TranslationPrompt`
|
||||
// (translate-and-polish) so the two pipelines stay aligned.
|
||||
//
|
||||
// Directives emphasize STRUCTURE (bullets, paragraphs, checklists)
|
||||
// over tone adjectives — structure is what users notice on short ASR
|
||||
// transcripts.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum ScenarioStyleDirective {
|
||||
|
||||
/// Format rules for the given scenario, written in the provider's
|
||||
/// primary instruction language (Chinese-native vs English-native).
|
||||
public static func make(
|
||||
scenarioId: String,
|
||||
providerId: String,
|
||||
uiLanguage: AppUILanguage? = nil
|
||||
) -> String {
|
||||
let id = PolishScenarioCatalog.resolve(scenarioId).id
|
||||
let lang = uiLanguage ?? AppGroupStore().uiLanguage
|
||||
let isChineseNative = ["zhipu", "moonshot", "qwen", "deepseek"].contains(providerId)
|
||||
return isChineseNative
|
||||
? chinese(id: id, uiLanguage: lang)
|
||||
: english(id: id, uiLanguage: lang)
|
||||
}
|
||||
|
||||
// MARK: - Chinese directives
|
||||
|
||||
private static func chinese(id: String, uiLanguage: AppUILanguage) -> String {
|
||||
let englishPlatformNames = uiLanguage.resolvedLanguageCode() != "zh-Hans"
|
||||
switch id {
|
||||
case "work":
|
||||
return """
|
||||
场景:工作沟通(邮件、钉钉、Slack)。
|
||||
格式(必须):
|
||||
- 若有 2 个及以上独立事项/请求/问题,必须用 markdown「- 」列表,每条一行;禁止揉进一段。
|
||||
- 仅 1 件事:可用 1~2 句短段落;必要时「称呼 + 正文」。
|
||||
- 每条 action 清晰;称呼得体;不过度敬语。
|
||||
允许:为列表组织内容,不必强行压成单句。
|
||||
禁止:把多项内容合并成一个长句或一整段散文。
|
||||
"""
|
||||
case "todo":
|
||||
return """
|
||||
场景:TODO/备忘清单。
|
||||
格式(必须):
|
||||
- 输出必须是 markdown「- 」列表,每条一行。
|
||||
- 每条以动词开头;一条一事;不写称呼、不写解释、不扩写。
|
||||
禁止:段落 prose、寒暄、背景说明。
|
||||
"""
|
||||
case "social_lifestyle":
|
||||
if englishPlatformNames {
|
||||
return """
|
||||
场景:社交网络生活分享帖(Social Network)。
|
||||
格式:
|
||||
- 内容≥2 句时必须用空行分段;第一人称;可读性强。
|
||||
- 可适度 emoji;不写广告腔;不编造体验。
|
||||
"""
|
||||
}
|
||||
return """
|
||||
场景:小红书生活分享帖。
|
||||
格式:
|
||||
- 内容≥2 句时必须用空行分段;第一人称;可读性强。
|
||||
- 可适度 emoji 与语气词;不写广告腔;不编造体验。
|
||||
"""
|
||||
case "social_short":
|
||||
if englishPlatformNames {
|
||||
return """
|
||||
场景:Instagram 短 caption。
|
||||
格式:句子短、开头抓人、信息密度高;控制总长度;不臆测标签或热点。
|
||||
"""
|
||||
}
|
||||
return """
|
||||
场景:微博短帖。
|
||||
格式:句子短、开头抓人、信息密度高;控制总长度;不臆测标签或热点。
|
||||
"""
|
||||
case "goofy":
|
||||
return """
|
||||
场景:轻松聊天(逗比风格)。
|
||||
格式:自然短句;措辞略俏皮。
|
||||
禁止:新增情节、编段子、捏造态度;严肃内容(请假/道歉/投诉)不要强行搞笑。
|
||||
"""
|
||||
case "document":
|
||||
return """
|
||||
场景:文档/长文笔记。
|
||||
格式:
|
||||
- 完整句;≥2 个主题时用空行分段。
|
||||
- 枚举或步骤用 markdown「- 」列表;可用 `##` 小标题(仅当内容够长)。
|
||||
- 少网络用语;比工作沟通更适合长文叙述。
|
||||
"""
|
||||
case "daily_chat", PolishScenarioCatalog.customId:
|
||||
fallthrough
|
||||
default:
|
||||
return """
|
||||
场景:日常聊天(IM/私聊)。
|
||||
格式:自然短句,像真人发消息;标点轻松;可保留极少量口语感。
|
||||
禁止:公文腔、报告体、强行列表(除非口述本身在枚举)。
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - English directives
|
||||
|
||||
private static func english(id: String, uiLanguage: AppUILanguage) -> String {
|
||||
let englishPlatformNames = uiLanguage.resolvedLanguageCode() != "zh-Hans"
|
||||
switch id {
|
||||
case "work":
|
||||
return """
|
||||
Scenario: workplace message (email, Slack, Teams).
|
||||
Format (required):
|
||||
- If there are 2+ distinct items/requests/questions, you MUST use markdown "- " bullets, one per line; never merge into one paragraph.
|
||||
- Single item only: 1–2 short sentences; optional greeting + body.
|
||||
- Clear action per item; polite but not overly formal.
|
||||
Allowed: list layout instead of forcing a single dense paragraph.
|
||||
Forbidden: cramming multiple points into one long sentence or prose block.
|
||||
"""
|
||||
case "todo":
|
||||
return """
|
||||
Scenario: TODO / checklist note.
|
||||
Format (required):
|
||||
- Output MUST be markdown "- " bullets, one item per line.
|
||||
- Each line starts with a verb; one task per line; no greeting, no explanation.
|
||||
Forbidden: prose paragraphs, filler, background context.
|
||||
"""
|
||||
case "social_lifestyle":
|
||||
if englishPlatformNames {
|
||||
return """
|
||||
Scenario: social network lifestyle post.
|
||||
Format: if ≥2 sentences, separate paragraphs with blank lines; first person; light emoji ok; no ad-speak; do not invent experiences.
|
||||
"""
|
||||
}
|
||||
return """
|
||||
Scenario: Xiaohongshu-style lifestyle share.
|
||||
Format: if ≥2 sentences, separate paragraphs with blank lines; first person; light emoji ok; no ad-speak; do not invent experiences.
|
||||
"""
|
||||
case "social_short":
|
||||
if englishPlatformNames {
|
||||
return """
|
||||
Scenario: short Instagram caption.
|
||||
Format: concise, punchy opening; high density; keep brief; no invented hashtags or trends.
|
||||
"""
|
||||
}
|
||||
return """
|
||||
Scenario: Weibo-style short post.
|
||||
Format: concise, punchy opening; high density; keep brief; no invented hashtags or trends.
|
||||
"""
|
||||
case "goofy":
|
||||
return """
|
||||
Scenario: playful chat (goofy tone).
|
||||
Format: natural short sentences; slightly witty wording only.
|
||||
Forbidden: new facts, invented jokes, forced humor on serious topics (leave/apology/complaint).
|
||||
"""
|
||||
case "document":
|
||||
return """
|
||||
Scenario: document / long-form notes.
|
||||
Format: complete sentences; blank lines between topics when ≥2 themes; use "- " bullets for steps/enumerations; `##` headings only when content is long enough; minimal slang.
|
||||
"""
|
||||
case "daily_chat", PolishScenarioCatalog.customId:
|
||||
fallthrough
|
||||
default:
|
||||
return """
|
||||
Scenario: everyday chat (IM/DM).
|
||||
Format: natural short sentences like texting; relaxed punctuation; very light colloquial tone ok.
|
||||
Forbidden: memo/report tone; forced bullets unless the speaker is enumerating.
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,8 @@
|
||||
// The "translate AND polish" blend is intentional: ASR transcripts are
|
||||
// noisy (homophone errors, broken segmentation, dropped particles), so
|
||||
// the prompt asks the model to clean the noise while translating.
|
||||
// Keeping those two concerns in one prompt matches how our existing
|
||||
// polish prompt already mixes "preserve meaning" with "fix punctuation /
|
||||
// drop filler".
|
||||
// Scenario output format (`ScenarioStyleDirective`) is appended so
|
||||
// translate-and-polish honours the user's polish scenario choice.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -26,36 +25,53 @@ public enum TranslationPrompt {
|
||||
/// - target: target language entry resolved via `TranslationLanguageCatalog`.
|
||||
/// - providerId: provider preset id (e.g. `"deepseek"`, `"openai"`);
|
||||
/// drives the language the prompt is written in.
|
||||
public static func make(target: TranslationLanguage, providerId: String) -> String {
|
||||
/// - scenarioId: active polish scenario; format rules are shared
|
||||
/// with the polish-only path via `ScenarioStyleDirective`.
|
||||
/// - uiLanguage: host-app UI language (platform label variants).
|
||||
public static func make(
|
||||
target: TranslationLanguage,
|
||||
providerId: String,
|
||||
scenarioId: String = PolishScenarioCatalog.defaultId,
|
||||
uiLanguage: AppUILanguage? = nil
|
||||
) -> String {
|
||||
let isChineseNative = ["zhipu", "moonshot", "qwen", "deepseek"].contains(providerId)
|
||||
return isChineseNative ? chinesePrompt(target: target) : englishPrompt(target: target)
|
||||
let directive = ScenarioStyleDirective.make(
|
||||
scenarioId: scenarioId,
|
||||
providerId: providerId,
|
||||
uiLanguage: uiLanguage
|
||||
)
|
||||
return isChineseNative
|
||||
? chinesePrompt(target: target, directive: directive)
|
||||
: englishPrompt(target: target, directive: directive)
|
||||
}
|
||||
|
||||
// MARK: - Chinese prompt (for DeepSeek / Qwen / GLM / Moonshot)
|
||||
|
||||
private static func chinesePrompt(target: TranslationLanguage) -> String {
|
||||
private static func chinesePrompt(target: TranslationLanguage, directive: String) -> String {
|
||||
"""
|
||||
你是一位语音输入翻译与润色助手。用户用 ASR 转写了一段可能含噪声的口述:
|
||||
1) 先识别原话的主要语言(若不确定则按用户给定的方向处理);
|
||||
2) 将内容翻译为「\(target.promptLanguageName)」,保留原意,不增删事实、不臆测;
|
||||
3) 顺带修复 ASR 噪声(同音错字、漏字、断句错乱),让译文读起来自然;
|
||||
4) 保留枚举结构(第一…第二…),使用「\(target.promptLanguageName)」的列表惯例;
|
||||
5) 简洁,不超过原文 1.5 倍;去掉无意义的口头禅(嗯、啊、那个);
|
||||
4) 简洁;若下方场景未要求列表/分段,不超过原文 1.5 倍;去掉无意义的口头禅(嗯、啊、那个);
|
||||
5) 若场景格式要求列表或分段,允许按格式组织译文;总长度不超过原长 2 倍;
|
||||
6) 只输出译文正文,不要解释、不要加引号、不要前缀"以下是翻译"。
|
||||
\(directive)
|
||||
"""
|
||||
}
|
||||
|
||||
// MARK: - English prompt (for OpenAI / OpenAI-compatible non-Chinese)
|
||||
|
||||
private static func englishPrompt(target: TranslationLanguage) -> String {
|
||||
private static func englishPrompt(target: TranslationLanguage, directive: String) -> String {
|
||||
"""
|
||||
You are a voice-input translation and polishing assistant. The user has spoken informally and the transcript may contain ASR noise:
|
||||
1) Identify the input language; if unclear, assume the user wants translation INTO \(target.promptLanguageName);
|
||||
2) Translate the content INTO \(target.promptLanguageName), preserving meaning; do not invent facts or omit content;
|
||||
3) Fix ASR noise (homophone errors, missing characters, broken segmentation) so the translation reads naturally;
|
||||
4) Preserve enumeration ("first ... second ...") using \(target.promptLanguageName) list conventions;
|
||||
5) Keep it concise — no longer than 1.5x the spoken length; drop filler words (um, uh, like);
|
||||
4) Stay concise; if the scenario below does not require lists/sections, do not exceed 1.5x the spoken length; drop filler words (um, uh, like);
|
||||
5) If the scenario requires bullets or paragraph breaks, use that layout in the translation; total length may be up to 2x when listing;
|
||||
6) Output ONLY the translation. No quotes, no preamble, no explanation.
|
||||
\(directive)
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"engine.asr.appleSpeech" = "Apple SpeechAnalyzer";
|
||||
|
||||
/* v0.2.0: flow-level warnings surfaced alongside the final transcript. */
|
||||
"flow.warning.cloudPolishMissingKey" = "Cloud polish is on, but no API key is set. Inserted the raw ASR transcript — fill in your DeepSeek key in Settings to enable polish.";
|
||||
"flow.warning.cloudPolishMissingKey" = "Cloud polish/translation needs a DeepSeek API key. Inserted raw ASR text — set PreconfiguredKeys.deepseek in the project (local engine) or API key in Settings (cloud engine).";
|
||||
|
||||
/* LLM providers */
|
||||
"provider.openai" = "OpenAI";
|
||||
@@ -30,3 +30,21 @@
|
||||
"error.asr.formatUnsupported" = "This device does not support the required audio format.";
|
||||
"error.asr.noSpeech" = "No speech detected. Please try again.";
|
||||
"error.asr.chunkFailed" = "Segment %lld failed: %@";
|
||||
|
||||
/* Polish scenarios */
|
||||
"polishScenario.daily_chat" = "Daily Chat";
|
||||
"polishScenario.social_lifestyle" = "Social Network";
|
||||
"polishScenario.social_short" = "Instagram";
|
||||
"polishScenario.goofy" = "Goofy";
|
||||
"polishScenario.work" = "Work";
|
||||
"polishScenario.document" = "Document";
|
||||
"polishScenario.todo" = "TODO";
|
||||
"polishScenario.custom" = "Custom";
|
||||
"polishScenario.chip.daily_chat" = "Chat";
|
||||
"polishScenario.chip.social_lifestyle" = "Social";
|
||||
"polishScenario.chip.social_short" = "IG";
|
||||
"polishScenario.chip.goofy" = "Goofy";
|
||||
"polishScenario.chip.work" = "Work";
|
||||
"polishScenario.chip.document" = "Doc";
|
||||
"polishScenario.chip.todo" = "TODO";
|
||||
"polishScenario.chip.custom" = "Custom";
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"engine.asr.appleSpeech" = "Apple 语音识别";
|
||||
|
||||
/* v0.2.0: flow-level warnings surfaced alongside the final transcript. */
|
||||
"flow.warning.cloudPolishMissingKey" = "已开启云端润色但未填写 API Key,本次以原始识别结果插入。请在设置中填入 DeepSeek API Key 以启用润色。";
|
||||
"flow.warning.cloudPolishMissingKey" = "云端润色/翻译需要 DeepSeek API Key,本次已插入原始识别结果。本地引擎请在 PreconfiguredKeys.swift 配置;云端引擎请在设置中填写 API Key。";
|
||||
|
||||
/* LLM providers */
|
||||
"provider.openai" = "OpenAI";
|
||||
@@ -30,3 +30,21 @@
|
||||
"error.asr.formatUnsupported" = "当前设备不支持该语音输入格式。";
|
||||
"error.asr.noSpeech" = "未识别到语音内容,请重试。";
|
||||
"error.asr.chunkFailed" = "第 %lld 段识别失败:%@";
|
||||
|
||||
/* 润色场景 */
|
||||
"polishScenario.daily_chat" = "日常聊天";
|
||||
"polishScenario.social_lifestyle" = "小红书";
|
||||
"polishScenario.social_short" = "微博";
|
||||
"polishScenario.goofy" = "逗比";
|
||||
"polishScenario.work" = "工作";
|
||||
"polishScenario.document" = "文档写作";
|
||||
"polishScenario.todo" = "TODO 记录";
|
||||
"polishScenario.custom" = "自定义";
|
||||
"polishScenario.chip.daily_chat" = "聊天";
|
||||
"polishScenario.chip.social_lifestyle" = "小红书";
|
||||
"polishScenario.chip.social_short" = "微博";
|
||||
"polishScenario.chip.goofy" = "逗比";
|
||||
"polishScenario.chip.work" = "工作";
|
||||
"polishScenario.chip.document" = "文档";
|
||||
"polishScenario.chip.todo" = "TODO";
|
||||
"polishScenario.chip.custom" = "自定义";
|
||||
|
||||
@@ -341,6 +341,196 @@ final class LLMClientTests: XCTestCase {
|
||||
let calls = await counter.value()
|
||||
XCTAssertEqual(calls, 0)
|
||||
}
|
||||
|
||||
/// Local engine pins DeepSeek — cloud-provider URL/model in App Group
|
||||
/// must not leak into the LLM request (regression: Qwen URL + DeepSeek key → 401).
|
||||
func testResolveLLMEndpointUsesPresetWhenProviderPinned() {
|
||||
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
defaults.set("qwen", forKey: "config.providerId")
|
||||
defaults.set(
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
forKey: "config.baseURL"
|
||||
)
|
||||
defaults.set("qwen-plus", forKey: "config.model")
|
||||
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
let deepseekPreset = LLMProvider.provider(id: "deepseek")
|
||||
let pinned = PolishingService.resolveLLMEndpoint(
|
||||
store: store,
|
||||
preset: deepseekPreset,
|
||||
providerIdOverride: "deepseek"
|
||||
)
|
||||
XCTAssertEqual(pinned.baseURL, deepseekPreset.defaultBaseURL)
|
||||
XCTAssertEqual(pinned.model, deepseekPreset.defaultModel)
|
||||
|
||||
let qwenPreset = LLMProvider.provider(id: "qwen")
|
||||
let cloud = PolishingService.resolveLLMEndpoint(
|
||||
store: store,
|
||||
preset: qwenPreset,
|
||||
providerIdOverride: nil
|
||||
)
|
||||
XCTAssertEqual(
|
||||
cloud.baseURL,
|
||||
"https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"cloud engine must keep user base URL"
|
||||
)
|
||||
XCTAssertEqual(cloud.model, "qwen-plus", "cloud engine must keep user model")
|
||||
}
|
||||
|
||||
func testTranslationChipVisibleWithoutTargetLocale() {
|
||||
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
defaults.set("cloud", forKey: "config.engineMode")
|
||||
defaults.set(TranslationLanguageCatalog.offLocaleId, forKey: "config.translationTargetLocaleId")
|
||||
let cloudStore = AppGroupStore(defaults: defaults)
|
||||
XCTAssertTrue(cloudStore.isTranslationChipVisible)
|
||||
XCTAssertFalse(cloudStore.isTranslationEffective)
|
||||
|
||||
defaults.set("local", forKey: "config.engineMode")
|
||||
defaults.set(true, forKey: "config.localModeCloudPolishEnabled")
|
||||
let localPolishOn = AppGroupStore(defaults: defaults)
|
||||
XCTAssertTrue(localPolishOn.isTranslationChipVisible)
|
||||
XCTAssertFalse(localPolishOn.isTranslationEffective)
|
||||
|
||||
defaults.set(false, forKey: "config.localModeCloudPolishEnabled")
|
||||
let localPolishOff = AppGroupStore(defaults: defaults)
|
||||
XCTAssertFalse(localPolishOff.isTranslationChipVisible)
|
||||
}
|
||||
|
||||
/// Local engine with translation enabled must invoke the LLM even
|
||||
/// when the cloud-polish toggle is off.
|
||||
func testPolisherSkipsLLMWhenLocalCloudPolishOffEvenWithTranslation() async throws {
|
||||
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
defaults.set("local", forKey: "config.engineMode")
|
||||
defaults.set("en", forKey: "config.translationTargetLocaleId")
|
||||
defaults.set(false, forKey: "config.localModeCloudPolishEnabled")
|
||||
|
||||
let counter = CallCounter()
|
||||
let countingClient = CountingLLMClient(counter: counter) { _, _ in
|
||||
XCTFail("cloud LLMClient must not run when local cloud polish is off")
|
||||
return ""
|
||||
}
|
||||
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
XCTAssertFalse(store.shouldRunCloudLLMStep)
|
||||
|
||||
let polisher = PolishingService(
|
||||
store: store,
|
||||
client: countingClient,
|
||||
timeout: 1
|
||||
)
|
||||
|
||||
let result = try await polisher.polish(
|
||||
" 你好 ",
|
||||
mode: .translate(targetLocaleId: "en"),
|
||||
providerIdOverride: "deepseek"
|
||||
)
|
||||
XCTAssertEqual(result, "你好")
|
||||
let calls = await counter.value()
|
||||
XCTAssertEqual(calls, 0)
|
||||
}
|
||||
|
||||
/// Local engine with cloud polish + translation enabled invokes LLM.
|
||||
func testPolisherTranslatesWhenLocalEngineTranslationEnabled() async throws {
|
||||
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
defaults.set("local", forKey: "config.engineMode")
|
||||
defaults.set("en", forKey: "config.translationTargetLocaleId")
|
||||
defaults.set(true, forKey: "config.localModeCloudPolishEnabled")
|
||||
|
||||
let counter = CallCounter()
|
||||
let countingClient = CountingLLMClient(counter: counter) { raw, prompt in
|
||||
XCTAssertEqual(raw, "你好")
|
||||
XCTAssertTrue(prompt.contains("English"), "translate prompt should target English")
|
||||
return "Hello"
|
||||
}
|
||||
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
let polisher = PolishingService(
|
||||
store: store,
|
||||
client: countingClient,
|
||||
timeout: 1
|
||||
)
|
||||
|
||||
let result = try await polisher.polish(
|
||||
" 你好 ",
|
||||
mode: .translate(targetLocaleId: "en"),
|
||||
providerIdOverride: "deepseek"
|
||||
)
|
||||
XCTAssertEqual(result, "Hello")
|
||||
let calls = await counter.value()
|
||||
XCTAssertEqual(calls, 1)
|
||||
}
|
||||
|
||||
func testResolvedPolishSystemPromptUsesWorkScenario() {
|
||||
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
defaults.set("work", forKey: "config.polishScenarioId")
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
let prompt = store.resolvedPolishSystemPrompt(providerId: "openai")
|
||||
XCTAssertTrue(prompt.localizedCaseInsensitiveContains("workplace"))
|
||||
}
|
||||
|
||||
func testCustomPolishScenarioUsesStoredSystemPrompt() {
|
||||
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
defaults.set(PolishScenarioCatalog.customId, forKey: "config.polishScenarioId")
|
||||
defaults.set("MY CUSTOM PROMPT", forKey: "config.systemPrompt")
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
XCTAssertEqual(store.resolvedPolishSystemPrompt(), "MY CUSTOM PROMPT")
|
||||
}
|
||||
|
||||
func testPolishScenarioChipVisibleWhenCloudPolishEnabledLocally() {
|
||||
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
defaults.set("local", forKey: "config.engineMode")
|
||||
defaults.set(true, forKey: "config.localModeCloudPolishEnabled")
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
XCTAssertTrue(store.isPolishScenarioChipVisible)
|
||||
}
|
||||
|
||||
func testTranslationPromptIncludesWorkScenarioBullets() {
|
||||
let prompt = TranslationPrompt.make(
|
||||
target: TranslationLanguageCatalog.resolve("en"),
|
||||
providerId: "openai",
|
||||
scenarioId: "work"
|
||||
)
|
||||
XCTAssertTrue(prompt.localizedCaseInsensitiveContains("MUST use markdown"))
|
||||
XCTAssertTrue(prompt.localizedCaseInsensitiveContains("workplace"))
|
||||
}
|
||||
|
||||
func testWorkScenarioPolishPromptRequiresBullets() {
|
||||
let prompt = ScenarioPrompt.make(scenarioId: "work", providerId: "openai")
|
||||
XCTAssertTrue(prompt.localizedCaseInsensitiveContains("MUST use markdown"))
|
||||
}
|
||||
|
||||
func testTodoScenarioPolishPromptRequiresChecklist() {
|
||||
let prompt = ScenarioPrompt.make(scenarioId: "todo", providerId: "deepseek")
|
||||
XCTAssertTrue(prompt.contains("必须是 markdown"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Test helpers
|
||||
|
||||
+2
-2
@@ -39,8 +39,8 @@ settings:
|
||||
GENERATE_INFOPLIST_FILE: NO
|
||||
ENABLE_MODULE_VERIFIER: YES
|
||||
CLANG_CXX_LANGUAGE_STANDARD: c++17
|
||||
MARKETING_VERSION: "0.2.0"
|
||||
CURRENT_PROJECT_VERSION: "4"
|
||||
MARKETING_VERSION: "0.3.0"
|
||||
CURRENT_PROJECT_VERSION: "5"
|
||||
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
||||
|
||||
# 项目级签名 xcconfig,适用于所有 target
|
||||
|
||||
Reference in New Issue
Block a user