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]
|
## [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
|
## [0.2.1] - 2026-06-24
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|||||||
@@ -560,11 +560,12 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
|
|
||||||
let engineMode = store.engineMode
|
let engineMode = store.engineMode
|
||||||
let chunkNote = Self.chunkWarningMessage(chunkWarnings)
|
let chunkNote = Self.chunkWarningMessage(chunkWarnings)
|
||||||
let shouldPolish = (engineMode == "cloud")
|
// Re-read App Group at finalize so chip-side translation changes
|
||||||
|| (engineMode == "local" && store.localModeCloudPolishEnabled)
|
// from the keyboard extension are visible before polish/translate.
|
||||||
|
let pipelineStore = AppGroupStore()
|
||||||
|
|
||||||
if !shouldPolish {
|
if !pipelineStore.shouldRunCloudLLMStep {
|
||||||
// Local engine, cloud-polish toggle off — pure ASR.
|
// Local engine with cloud polish off — ASR-only.
|
||||||
FlowSessionBridge.storeTranscriptionResult(text, polishWarning: chunkNote)
|
FlowSessionBridge.storeTranscriptionResult(text, polishWarning: chunkNote)
|
||||||
FlowDiagnostics.log(
|
FlowDiagnostics.log(
|
||||||
"finalize ASR-only total=\(String(format: "%.1f", Date().timeIntervalSince(pipelineStarted)))s " +
|
"finalize ASR-only total=\(String(format: "%.1f", Date().timeIntervalSince(pipelineStarted)))s " +
|
||||||
@@ -580,8 +581,18 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
|
|
||||||
var delivered = text
|
var delivered = text
|
||||||
let polishStarted = Date()
|
let polishStarted = Date()
|
||||||
|
let polishMode = pipelineStore.polishModeForPipeline
|
||||||
|
FlowDiagnostics.log(
|
||||||
|
"finalize LLM mode=\(Self.polishModeLogLabel(polishMode)) " +
|
||||||
|
"translationTarget=\(pipelineStore.translationTargetLocaleId) " +
|
||||||
|
"cloudPolish=\(pipelineStore.localModeCloudPolishEnabled)"
|
||||||
|
)
|
||||||
do {
|
do {
|
||||||
let polished = try await polisher.polish(text)
|
let polished = try await polisher.polish(
|
||||||
|
text,
|
||||||
|
mode: polishMode,
|
||||||
|
providerIdOverride: pipelineStore.polishProviderIdOverride
|
||||||
|
)
|
||||||
delivered = polished
|
delivered = polished
|
||||||
FlowSessionBridge.storeTranscriptionResult(polished, polishWarning: chunkNote)
|
FlowSessionBridge.storeTranscriptionResult(polished, polishWarning: chunkNote)
|
||||||
FlowDiagnostics.log(
|
FlowDiagnostics.log(
|
||||||
@@ -611,6 +622,15 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
debug("utterance finalized length=\(text.count)")
|
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? {
|
private static func chunkWarningMessage(_ warnings: [String]) -> String? {
|
||||||
guard !warnings.isEmpty else { return nil }
|
guard !warnings.isEmpty else { return nil }
|
||||||
return warnings.joined(separator: "\n")
|
return warnings.joined(separator: "\n")
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ struct KeyboardPreviewStub: View {
|
|||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
.padding(.bottom, 6)
|
.padding(.bottom, 6)
|
||||||
}
|
}
|
||||||
.frame(height: 280)
|
.frame(height: 240)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Top bar
|
// MARK: - Top bar
|
||||||
@@ -60,7 +60,6 @@ struct KeyboardPreviewStub: View {
|
|||||||
modeChip
|
modeChip
|
||||||
localeChip
|
localeChip
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
statusBadge
|
|
||||||
Button(action: openSettings) {
|
Button(action: openSettings) {
|
||||||
Image(systemName: "gearshape.fill")
|
Image(systemName: "gearshape.fill")
|
||||||
.font(.system(size: 13, weight: .medium))
|
.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
|
// MARK: - Centre area
|
||||||
|
|
||||||
private var centreArea: some View {
|
private var centreArea: some View {
|
||||||
|
|||||||
@@ -734,23 +734,6 @@ private struct APISetupPage: View {
|
|||||||
.padding(.horizontal, Spacing.lg)
|
.padding(.horizontal, Spacing.lg)
|
||||||
APISettingsCard(config: config)
|
APISettingsCard(config: config)
|
||||||
.padding(.horizontal, Spacing.lg)
|
.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 {
|
} else {
|
||||||
// v0.2.0: local engine is iOS `SpeechAnalyzer` only.
|
// v0.2.0: local engine is iOS `SpeechAnalyzer` only.
|
||||||
// Surface the cloud-polish toggle and a one-line
|
// Surface the cloud-polish toggle and a one-line
|
||||||
@@ -772,39 +755,32 @@ private struct APISetupPage: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, Spacing.lg)
|
.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)
|
.padding(.bottom, Spacing.xxxl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// v0.2.1 follow-up: extracted so both engine branches can render
|
/// Polish scenario + optional translation target for cloud onboarding.
|
||||||
/// the same surface card + picker. `TranslationPickerRow` itself
|
private var postProcessingSection: some View {
|
||||||
/// 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 {
|
|
||||||
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
||||||
Text("settings.translation.afterPolish")
|
Text("settings.polishScenario.section")
|
||||||
.font(TypeStyle.caption2)
|
.font(TypeStyle.caption2)
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
.textCase(.uppercase)
|
.textCase(.uppercase)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
ScenarioPickerRow(config: config, isVisible: true)
|
||||||
|
if config.isTranslationRowVisible {
|
||||||
|
Divider().background(palette.divider)
|
||||||
TranslationPickerRow(config: config, isVisible: true)
|
TranslationPickerRow(config: config, isVisible: true)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||||
.overlay(
|
.overlay(
|
||||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||||
|
|||||||
@@ -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) {
|
VStack(spacing: Spacing.md) {
|
||||||
appLanguageSection
|
appLanguageSection
|
||||||
engineSection
|
engineSection
|
||||||
|
languageAndPolishSection
|
||||||
// v0.2.1: hide provider/api card when the
|
// v0.2.1: hide provider/api card when the
|
||||||
// local engine is active regardless of the
|
// local engine is active regardless of the
|
||||||
// cloud-polish toggle. Local mode is
|
// cloud-polish toggle. Local mode is
|
||||||
@@ -73,13 +74,9 @@ struct SettingsView: View {
|
|||||||
providerSection
|
providerSection
|
||||||
apiSection
|
apiSection
|
||||||
}
|
}
|
||||||
languageAndModelsSection
|
|
||||||
if config.engineMode == "local" {
|
if config.engineMode == "local" {
|
||||||
localEngineSettingsSection
|
localEngineSettingsSection
|
||||||
}
|
}
|
||||||
if config.engineMode == "cloud" {
|
|
||||||
systemPromptLinkSection
|
|
||||||
}
|
|
||||||
if presentation == .tab {
|
if presentation == .tab {
|
||||||
footerLinks
|
footerLinks
|
||||||
}
|
}
|
||||||
@@ -124,19 +121,12 @@ struct SettingsView: View {
|
|||||||
EnginePickerSection(config: config)
|
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) {
|
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
||||||
sectionHeader("settings.language.title")
|
sectionHeader("settings.languageAndPolish.title")
|
||||||
VStack(spacing: 0) {
|
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(
|
LocalePickerRow(
|
||||||
locales: effectiveLocales,
|
locales: effectiveLocales,
|
||||||
selection: Binding(
|
selection: Binding(
|
||||||
@@ -144,6 +134,23 @@ struct SettingsView: View {
|
|||||||
set: { config.localeId = $0 }
|
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))
|
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||||
.overlay(
|
.overlay(
|
||||||
@@ -246,27 +253,6 @@ struct SettingsView: View {
|
|||||||
dynamicLocales = entries
|
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)
|
// MARK: - Footer links (tab settings only)
|
||||||
|
|
||||||
private var footerLinks: some View {
|
private var footerLinks: some View {
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ struct SystemPromptSettingsView: View {
|
|||||||
.foregroundStyle(palette.textTertiary)
|
.foregroundStyle(palette.textTertiary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.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)
|
TextEditor(text: $config.systemPrompt)
|
||||||
.font(TypeStyle.mono)
|
.font(TypeStyle.mono)
|
||||||
.scrollContentBackground(.hidden)
|
.scrollContentBackground(.hidden)
|
||||||
|
|||||||
@@ -108,9 +108,14 @@
|
|||||||
"provider.custom" = "Custom";
|
"provider.custom" = "Custom";
|
||||||
"settings.api.title" = "API";
|
"settings.api.title" = "API";
|
||||||
"settings.language.title" = "Language";
|
"settings.language.title" = "Language";
|
||||||
|
"settings.languageAndPolish.title" = "Language & Polish";
|
||||||
// v0.2.1: translation feature
|
// v0.2.1: translation feature
|
||||||
"settings.translation.afterPolish" = "Polish then translate";
|
"settings.translation.afterPolish" = "Polish then translate";
|
||||||
"settings.translation.off" = "Don't 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.languageModels.title" = "Language & models";
|
||||||
"settings.localModels.title" = "On-device models";
|
"settings.localModels.title" = "On-device models";
|
||||||
"settings.localModels.speechRole" = "Speech";
|
"settings.localModels.speechRole" = "Speech";
|
||||||
|
|||||||
@@ -98,7 +98,7 @@
|
|||||||
"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。";
|
"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。";
|
||||||
"settings.engine.cloud.title" = "云端识别与润色";
|
"settings.engine.cloud.title" = "云端识别与润色";
|
||||||
"settings.engine.cloud.subtitle" = "本地转写 + 你配置的 API 润色,文字发往该第三方服务";
|
"settings.engine.cloud.subtitle" = "本地转写 + 你配置的 API 润色,文字发往该第三方服务";
|
||||||
"settings.provider.title" = "提供商";
|
"settings.provider.title" = "云端引擎";
|
||||||
"settings.provider.subtitle" = "选择 LLM 提供商。";
|
"settings.provider.subtitle" = "选择 LLM 提供商。";
|
||||||
"provider.openai" = "OpenAI";
|
"provider.openai" = "OpenAI";
|
||||||
"provider.deepseek" = "DeepSeek";
|
"provider.deepseek" = "DeepSeek";
|
||||||
@@ -108,9 +108,14 @@
|
|||||||
"provider.custom" = "自定义";
|
"provider.custom" = "自定义";
|
||||||
"settings.api.title" = "接口";
|
"settings.api.title" = "接口";
|
||||||
"settings.language.title" = "语言";
|
"settings.language.title" = "语言";
|
||||||
|
"settings.languageAndPolish.title" = "语言与润色";
|
||||||
// v0.2.1: 翻译功能
|
// v0.2.1: 翻译功能
|
||||||
"settings.translation.afterPolish" = "润色后翻译";
|
"settings.translation.afterPolish" = "润色后翻译";
|
||||||
"settings.translation.off" = "不翻译";
|
"settings.translation.off" = "不翻译";
|
||||||
|
"settings.polishScenario.section" = "润色";
|
||||||
|
"settings.polishScenario.title" = "润色场景";
|
||||||
|
"settings.polishScenario.hint" = "选择适合的使用场景。选「自定义」可编辑完整润色指令。";
|
||||||
|
"settings.polishScenario.customHint" = "自定义场景:在下方编辑完整润色指令。";
|
||||||
"settings.languageModels.title" = "语言与模型";
|
"settings.languageModels.title" = "语言与模型";
|
||||||
"settings.localModels.title" = "本地模型";
|
"settings.localModels.title" = "本地模型";
|
||||||
"settings.localModels.speechRole" = "语音识别";
|
"settings.localModels.speechRole" = "语音识别";
|
||||||
|
|||||||
@@ -74,24 +74,36 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
private var wasFlowSessionActive = false
|
private var wasFlowSessionActive = false
|
||||||
private var flowSessionMonitorTask: Task<Void, Never>?
|
private var flowSessionMonitorTask: Task<Void, Never>?
|
||||||
private var flowSessionDarwinObserver: FlowSessionDarwinObserver?
|
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 isAwaitingFlowResult = false
|
||||||
private var lastFlowAutoStartAttempt: TimeInterval = 0
|
private var lastFlowAutoStartAttempt: TimeInterval = 0
|
||||||
private static let flowAutoStartCooldown: TimeInterval = 20
|
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
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
public override func viewDidLoad() {
|
public override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
// Keyboard extension MUST opt in to self-sizing, otherwise
|
installKeyboardHeight()
|
||||||
// our SwiftUI `frame(height:)` is ignored and the keyboard is
|
configureDictationBehavior()
|
||||||
// cropped by the system chrome (Spotlight bar, home indicator).
|
|
||||||
inputView?.allowsSelfSizing = true
|
|
||||||
installStateActions()
|
installStateActions()
|
||||||
installSwiftUI()
|
installSwiftUI()
|
||||||
loadPersistedConfig()
|
loadPersistedConfig()
|
||||||
consumePendingDictationResultIfNeeded()
|
consumePendingDictationResultIfNeeded()
|
||||||
refreshDictationProgressStateIfNeeded()
|
refreshDictationProgressStateIfNeeded()
|
||||||
installFlowSessionDarwinObserver()
|
installFlowSessionDarwinObserver()
|
||||||
|
installConfigDarwinObserver()
|
||||||
refreshFlowSessionState()
|
refreshFlowSessionState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +120,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
|
|
||||||
public override func viewWillAppear(_ animated: Bool) {
|
public override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
|
configureDictationBehavior()
|
||||||
KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess)
|
KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess)
|
||||||
consumePendingDictationResultIfNeeded()
|
consumePendingDictationResultIfNeeded()
|
||||||
refreshDictationProgressStateIfNeeded()
|
refreshDictationProgressStateIfNeeded()
|
||||||
@@ -115,6 +128,17 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
startFlowSessionMonitor()
|
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() {
|
public override func didReceiveMemoryWarning() {
|
||||||
super.didReceiveMemoryWarning()
|
super.didReceiveMemoryWarning()
|
||||||
cancelPipeline()
|
cancelPipeline()
|
||||||
@@ -126,6 +150,14 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
refreshDictationProgressStateIfNeeded()
|
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
|
// MARK: - Wiring
|
||||||
|
|
||||||
private func installStateActions() {
|
private func installStateActions() {
|
||||||
@@ -141,16 +173,49 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
// v0.2.1 follow-up: removed `setTranslationEnabled` — the chip
|
// v0.2.1 follow-up: removed `setTranslationEnabled` — the chip
|
||||||
// / picker only writes the locale id now; `enabled` is derived.
|
// / picker only writes the locale id now; `enabled` is derived.
|
||||||
state.setTranslationTargetLocaleId = { [weak self] id in self?.persistTranslationTargetLocaleId(id) }
|
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.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") }
|
||||||
state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") }
|
state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") }
|
||||||
state.deleteBackward = { [weak self] in self?.textDocumentProxy.deleteBackward() }
|
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() {
|
private func installSwiftUI() {
|
||||||
let root = KeyboardRootView(state: state)
|
let root = KeyboardRootView(state: state)
|
||||||
let host = UIHostingController(rootView: root)
|
let host = UIHostingController(rootView: root)
|
||||||
host.view.backgroundColor = .clear
|
host.view.backgroundColor = .clear
|
||||||
host.view.translatesAutoresizingMaskIntoConstraints = false
|
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)
|
addChild(host)
|
||||||
view.addSubview(host.view)
|
view.addSubview(host.view)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
@@ -158,11 +223,6 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
host.view.topAnchor.constraint(equalTo: view.topAnchor),
|
host.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
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)
|
host.didMove(toParent: self)
|
||||||
self.hosting = host
|
self.hosting = host
|
||||||
@@ -203,8 +263,28 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
flowSessionMonitorTask = nil
|
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() {
|
private func refreshFlowSessionState() {
|
||||||
persistor.refreshRuntimeFlags(into: state)
|
persistor.refreshRuntimeFlags(
|
||||||
|
into: state,
|
||||||
|
protectTranslationUntil: translationConfigProtectedUntil,
|
||||||
|
protectPolishScenarioUntil: polishScenarioConfigProtectedUntil
|
||||||
|
)
|
||||||
consumePendingFlowDeliveryIfNeeded()
|
consumePendingFlowDeliveryIfNeeded()
|
||||||
|
|
||||||
let active = FlowSessionBridge.isSessionActive()
|
let active = FlowSessionBridge.isSessionActive()
|
||||||
@@ -515,8 +595,9 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
debug("received transcript length=\(trimmed.count)")
|
debug("received transcript length=\(trimmed.count)")
|
||||||
awaitingDictationResult = false
|
awaitingDictationResult = false
|
||||||
stopDictationWatchdog()
|
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)
|
textDocumentProxy.insertText(trimmed)
|
||||||
state.lastTranscript = ""
|
state.lastTranscript = ""
|
||||||
if let warning = delivery.polishWarning {
|
if let warning = delivery.polishWarning {
|
||||||
@@ -527,28 +608,13 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Cloud engine: always polish via the configured LLM.
|
|
||||||
|
// Cloud engine, or local engine with cloud polish / translation.
|
||||||
state.phase = .processing
|
state.phase = .processing
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
// v0.2.1: pick the polish mode once at task start so a
|
let polishMode = runtimeStore.polishModeForPipeline
|
||||||
// mid-flight toggle flip doesn't change the request we
|
let overrideProviderId = runtimeStore.polishProviderIdOverride
|
||||||
// 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
|
|
||||||
do {
|
do {
|
||||||
let polished = try await self.polisher.polish(
|
let polished = try await self.polisher.polish(
|
||||||
trimmed,
|
trimmed,
|
||||||
@@ -656,9 +722,17 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
private func persistTranslationTargetLocaleId(_ id: String) {
|
private func persistTranslationTargetLocaleId(_ id: String) {
|
||||||
let resolved = TranslationLanguageCatalog.resolve(id).id
|
let resolved = TranslationLanguageCatalog.resolve(id).id
|
||||||
state.translationTargetLocaleId = resolved
|
state.translationTargetLocaleId = resolved
|
||||||
|
translationConfigProtectedUntil = Date().addingTimeInterval(2.5)
|
||||||
persistor.persist(translationTargetLocaleId: resolved)
|
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
|
// MARK: - Open host app
|
||||||
|
|
||||||
private func openHostApp(path: String = "settings") {
|
private func openHostApp(path: String = "settings") {
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ public struct AppGroupPersistor {
|
|||||||
// startup; `refreshRuntimeFlags` keeps the chip in sync while
|
// startup; `refreshRuntimeFlags` keeps the chip in sync while
|
||||||
// the keyboard stays open.
|
// the keyboard stays open.
|
||||||
state.translationTargetLocaleId = store.translationTargetLocaleId
|
state.translationTargetLocaleId = store.translationTargetLocaleId
|
||||||
|
state.polishScenarioId = store.polishScenarioId
|
||||||
|
state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled
|
||||||
// v0.2.0: iOS `SpeechAnalyzer` is always ready; mirror that
|
// v0.2.0: iOS `SpeechAnalyzer` is always ready; mirror that
|
||||||
// into the State flags so downstream consumers see the same
|
// into the State flags so downstream consumers see the same
|
||||||
// shape they did when the previous Qwen3 stack reported "ready".
|
// 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
|
/// Lightweight refresh for flags the host app may update while the
|
||||||
/// keyboard stays open (model downloads, engine switches).
|
/// 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 }
|
guard AppGroup.isAvailable else { return }
|
||||||
let store = AppGroupStore()
|
let store = AppGroupStore()
|
||||||
state.engineMode = store.engineMode
|
state.engineMode = store.engineMode
|
||||||
state.localASRBackend = store.localASRBackend
|
state.localASRBackend = store.localASRBackend
|
||||||
// v0.2.1 follow-up: same as `load` — only the locale is
|
state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled
|
||||||
// persisted, `enabled` is derived.
|
let shouldProtectTranslation = protectTranslationUntil.map { Date() < $0 } ?? false
|
||||||
|
if !shouldProtectTranslation {
|
||||||
state.translationTargetLocaleId = store.translationTargetLocaleId
|
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
|
// v0.2.0: iOS `SpeechAnalyzer` is always ready. Keep these
|
||||||
// toggles here so the keyboard UI doesn't flicker if the host
|
// toggles here so the keyboard UI doesn't flicker if the host
|
||||||
// app briefly clears them while refactoring.
|
// app briefly clears them while refactoring.
|
||||||
@@ -127,4 +143,9 @@ public struct AppGroupPersistor {
|
|||||||
guard AppGroup.isAvailable else { return }
|
guard AppGroup.isAvailable else { return }
|
||||||
AppGroupStore().setTranslationTargetLocaleId(translationTargetLocaleId)
|
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
|
// Typeless-inspired keyboard surface. The keyboard is laid out in three
|
||||||
// vertical bands, but the entire height is reserved for us — we set
|
// vertical bands, but the entire height is reserved for us — we set
|
||||||
// `inputView.allowsSelfSizing = true` in the view controller so SwiftUI's
|
// `KeyboardViewController` drives height on `view` (priority 999) and mirrors
|
||||||
// frame is honoured, and we add safe-area insets at the top and bottom so
|
// `KeyboardLayoutMetrics.totalHeight` in SwiftUI — see presentation offset
|
||||||
// the system Spotlight / home-indicator chrome never clips our controls.
|
// in `applyPresentationHeightOffset()`.
|
||||||
//
|
//
|
||||||
// ┌───────────────────────────────────────────┐
|
// ┌───────────────────────────────────────────┐
|
||||||
// │ [polish] [中] ● ⚙ │ ← top: ~38 pt (+20%)
|
// │ [polish] [中] ⚙ │ ← header band (top)
|
||||||
// │ (transcript preview) │
|
// │ (transcript preview) │
|
||||||
// │ │
|
// │ ┊ │
|
||||||
// │ (⌫) ◯ mic (↩) │ ← action row: circular
|
// │ (⌫) ◯ mic (↩) │ ← action cluster:
|
||||||
// │ (space) │ flanking buttons
|
// │ (space) │ centred below header
|
||||||
|
// │ ┊ │
|
||||||
// └───────────────────────────────────────────┘
|
// └───────────────────────────────────────────┘
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -24,8 +25,33 @@ private enum KeyboardLayoutMetrics {
|
|||||||
static let sideSpaceBarWidth: CGFloat = 19
|
static let sideSpaceBarWidth: CGFloat = 19
|
||||||
static let micFlankMinSpacing: CGFloat = 36
|
static let micFlankMinSpacing: CGFloat = 36
|
||||||
static let sideActionStackSpacing: CGFloat = 16
|
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%).
|
/// Outer inset for delete / return·space from screen edges (8 pt → 24 pt, +200%).
|
||||||
static let sideActionHorizontalInset: CGFloat = Spacing.xs * 3
|
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 {
|
public struct KeyboardRootView: View {
|
||||||
@@ -37,11 +63,9 @@ public struct KeyboardRootView: View {
|
|||||||
self.state = state
|
self.state = state
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Total keyboard height. We set the same value as a height-anchor
|
/// Content-driven keyboard height; mirrored on `UIInputViewController.view`
|
||||||
/// constraint in the view controller so the host UIInputView picks
|
/// in `KeyboardViewController` (see `KeyboardLayoutMetrics.totalHeight`).
|
||||||
/// it up.
|
static let totalHeight: CGFloat = KeyboardLayoutMetrics.totalHeight
|
||||||
static let totalHeight: CGFloat = 280
|
|
||||||
private static let topBarHeight: CGFloat = 38
|
|
||||||
|
|
||||||
private var palette: ThemePalette {
|
private var palette: ThemePalette {
|
||||||
colorScheme == .dark ? Palette.dark : Palette.light
|
colorScheme == .dark ? Palette.dark : Palette.light
|
||||||
@@ -49,14 +73,19 @@ public struct KeyboardRootView: View {
|
|||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
topBar
|
headerBand
|
||||||
.frame(height: Self.topBarHeight)
|
|
||||||
|
|
||||||
centreArea
|
Color.clear
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(height: KeyboardLayoutMetrics.actionClusterVerticalGap)
|
||||||
|
|
||||||
|
micActionRow
|
||||||
|
.frame(height: KeyboardLayoutMetrics.actionClusterHeight)
|
||||||
|
|
||||||
|
Color.clear
|
||||||
|
.frame(height: KeyboardLayoutMetrics.actionClusterVerticalGap)
|
||||||
}
|
}
|
||||||
.padding(.top, 4)
|
.padding(.top, KeyboardLayoutMetrics.outerPaddingTop)
|
||||||
.padding(.bottom, 6)
|
.padding(.bottom, KeyboardLayoutMetrics.outerPaddingBottom)
|
||||||
// 透明背景:让系统键盘 chrome 透出,不自行铺色(深浅模式一致)。
|
// 透明背景:让系统键盘 chrome 透出,不自行铺色(深浅模式一致)。
|
||||||
.background(Color.clear)
|
.background(Color.clear)
|
||||||
.frame(height: Self.totalHeight)
|
.frame(height: Self.totalHeight)
|
||||||
@@ -64,6 +93,26 @@ public struct KeyboardRootView: View {
|
|||||||
.environment(\.themePalette, palette)
|
.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
|
// MARK: - Top bar
|
||||||
|
|
||||||
private var topBar: some View {
|
private var topBar: some View {
|
||||||
@@ -73,22 +122,20 @@ public struct KeyboardRootView: View {
|
|||||||
} else {
|
} else {
|
||||||
CloudEngineChip()
|
CloudEngineChip()
|
||||||
}
|
}
|
||||||
|
if state.isPolishScenarioChipVisible {
|
||||||
|
ScenarioChip(state: state)
|
||||||
|
}
|
||||||
LocaleChip(localeId: state.localeId) { newId in
|
LocaleChip(localeId: state.localeId) { newId in
|
||||||
state.setLocale(newId)
|
state.setLocale(newId)
|
||||||
}
|
}
|
||||||
// v0.2.1: translation chip — sits next to the locale picker
|
// v0.3: always show the translation chip when the active
|
||||||
// and doubles as both the on/off switch and the target-
|
// engine can run the cloud LLM step — off-by-default keeps
|
||||||
// language picker (Menu pattern matches LocaleChip so the
|
// the menu reachable so the user can pick a target language
|
||||||
// top bar stays visually consistent).
|
// without opening Settings.
|
||||||
// v0.2.1 final review: only render the chip when translation
|
if state.isTranslationChipVisible {
|
||||||
// is actually on. Off-by-default keeps the top bar compact
|
|
||||||
// for users who don't need translation; the menu still lives
|
|
||||||
// in onboarding so the feature is discoverable.
|
|
||||||
if state.translationEnabled {
|
|
||||||
TranslationChip(state: state)
|
TranslationChip(state: state)
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
StatusBadge(phase: state.phase, onDeviceSupported: state.onDeviceSupported)
|
|
||||||
Button(action: state.openSettings) {
|
Button(action: state.openSettings) {
|
||||||
Image(systemName: "gearshape.fill")
|
Image(systemName: "gearshape.fill")
|
||||||
.font(.system(size: 14, weight: .medium))
|
.font(.system(size: 14, weight: .medium))
|
||||||
@@ -103,35 +150,11 @@ public struct KeyboardRootView: View {
|
|||||||
.padding(.horizontal, Spacing.md)
|
.padding(.horizontal, Spacing.md)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Centre area
|
// MARK: - Action cluster
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete (left), mic (centre), return + space stacked on the right.
|
/// Delete (left), mic (centre), return + space stacked on the right.
|
||||||
/// HStack vertical alignment keeps delete, mic centre, and the gap
|
/// Fixed vertical gaps in `body` keep the cluster centred without
|
||||||
/// between return/space on one horizontal axis.
|
/// flexible Spacers consuming extra keyboard height.
|
||||||
private var micActionRow: some View {
|
private var micActionRow: some View {
|
||||||
HStack(alignment: .center, spacing: 0) {
|
HStack(alignment: .center, spacing: 0) {
|
||||||
CircularToolbarButton(systemName: "delete.left", label: "delete") {
|
CircularToolbarButton(systemName: "delete.left", label: "delete") {
|
||||||
@@ -186,19 +209,19 @@ extension KeyboardRootView {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
#Preview("Keyboard · Idle") {
|
#Preview("Keyboard · Idle") {
|
||||||
KeyboardRootView(state: KeyboardViewController.State.previewIdle)
|
KeyboardRootView(state: KeyboardViewController.State.previewIdle)
|
||||||
.frame(width: 390, height: 280)
|
.frame(width: 390, height: KeyboardRootView.totalHeight)
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview("Keyboard · Recording") {
|
#Preview("Keyboard · Recording") {
|
||||||
KeyboardRootView(state: KeyboardViewController.State.previewRecording)
|
KeyboardRootView(state: KeyboardViewController.State.previewRecording)
|
||||||
.frame(width: 390, height: 280)
|
.frame(width: 390, height: KeyboardRootView.totalHeight)
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview("Keyboard · Processing") {
|
#Preview("Keyboard · Processing") {
|
||||||
KeyboardRootView(state: KeyboardViewController.State.previewProcessing)
|
KeyboardRootView(state: KeyboardViewController.State.previewProcessing)
|
||||||
.frame(width: 390, height: 280)
|
.frame(width: 390, height: KeyboardRootView.totalHeight)
|
||||||
.preferredColorScheme(.dark)
|
.preferredColorScheme(.dark)
|
||||||
}
|
}
|
||||||
#endif
|
#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)
|
// MARK: - Cloud engine chip (cloud always ASR + LLM polish)
|
||||||
|
|
||||||
private struct CloudEngineChip: View {
|
private struct CloudEngineChip: View {
|
||||||
|
|||||||
@@ -42,11 +42,20 @@ struct RecordButton: View {
|
|||||||
return remainingSeconds <= 10
|
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 {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
.stroke(palette.recordRed.opacity(isUrgent ? 0.55 : 0.35), lineWidth: isUrgent ? 3 : 2)
|
.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)
|
.scaleEffect(breath ? 1.18 : 0.95)
|
||||||
.opacity(phase == .recording ? 1 : 0)
|
.opacity(phase == .recording ? 1 : 0)
|
||||||
.animation(Motion.breath, value: breath)
|
.animation(Motion.breath, value: breath)
|
||||||
@@ -60,7 +69,7 @@ struct RecordButton: View {
|
|||||||
endRadius: 100
|
endRadius: 100
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.frame(width: 200, height: 200)
|
.frame(width: Layout.glow, height: Layout.glow)
|
||||||
.opacity(phase == .recording ? 0.4 + level * 0.6 : 0)
|
.opacity(phase == .recording ? 0.4 + level * 0.6 : 0)
|
||||||
.blur(radius: 18)
|
.blur(radius: 18)
|
||||||
.animation(Motion.soft, value: phase)
|
.animation(Motion.soft, value: phase)
|
||||||
@@ -71,7 +80,7 @@ struct RecordButton: View {
|
|||||||
Color.white.opacity(phase == .idle ? 0.08 : 0.12),
|
Color.white.opacity(phase == .idle ? 0.08 : 0.12),
|
||||||
lineWidth: 0.5
|
lineWidth: 0.5
|
||||||
)
|
)
|
||||||
.frame(width: 140, height: 140)
|
.frame(width: Layout.outerRing, height: Layout.outerRing)
|
||||||
|
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
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: phase)
|
||||||
.animation(Motion.soft, value: remainingSeconds)
|
.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.
|
// off / on, with the same accent treatment either way.
|
||||||
//
|
//
|
||||||
// Visual states:
|
// Visual states:
|
||||||
// • off → dim outline, "翻译" label
|
// • off → dim outline, "翻译" chip label (menu first row = "不翻译")
|
||||||
// • on (any engine) → accent fill, "→ EN" / "→ 日本語" style label
|
// • on (any engine) → accent fill, "→ EN" / "→ 日本語" style label
|
||||||
//
|
//
|
||||||
// Stays in the same visual family as `CloudEngineChip` / `LocaleChip`
|
// Stays in the same visual family as `CloudEngineChip` / `LocaleChip`
|
||||||
@@ -88,14 +88,14 @@ struct TranslationChip: View {
|
|||||||
|
|
||||||
private func displayLabel(for language: TranslationLanguage) -> String {
|
private func displayLabel(for language: TranslationLanguage) -> String {
|
||||||
if language.id == TranslationLanguageCatalog.offLocaleId {
|
if language.id == TranslationLanguageCatalog.offLocaleId {
|
||||||
return ExtL10n.string("keyboard.translation.off")
|
return ExtL10n.string("keyboard.translation.offMenu")
|
||||||
}
|
}
|
||||||
return language.nativeName
|
return language.nativeName
|
||||||
}
|
}
|
||||||
|
|
||||||
private func chipLabel(target: TranslationLanguage, enabled: Bool) -> String {
|
private func chipLabel(target: TranslationLanguage, enabled: Bool) -> String {
|
||||||
if !enabled {
|
if !enabled {
|
||||||
return ExtL10n.string("keyboard.translation.off")
|
return ExtL10n.string("keyboard.translation.chip")
|
||||||
}
|
}
|
||||||
// Short form: "→EN" / "→日" style. Falls back to the prompt
|
// Short form: "→EN" / "→日" style. Falls back to the prompt
|
||||||
// language name for languages without a chip-style abbreviation
|
// language name for languages without a chip-style abbreviation
|
||||||
|
|||||||
@@ -176,12 +176,16 @@
|
|||||||
"locale.chip.ja-JP" = "日";
|
"locale.chip.ja-JP" = "日";
|
||||||
"locale.chip.ko-KR" = "韩";
|
"locale.chip.ko-KR" = "韩";
|
||||||
|
|
||||||
/* Translation chip (v0.2.1) */
|
/* Translation chip (v0.3) */
|
||||||
"keyboard.translation.off" = "Translate";
|
"keyboard.translation.chip" = "Translate";
|
||||||
|
"keyboard.translation.offMenu" = "Don't translate";
|
||||||
|
"keyboard.translation.off" = "Don't translate";
|
||||||
"keyboard.translation.enable" = "Enable translation";
|
"keyboard.translation.enable" = "Enable translation";
|
||||||
"keyboard.translation.disable" = "Disable translation";
|
"keyboard.translation.disable" = "Disable translation";
|
||||||
"keyboard.translation.a11y" = "Translation";
|
"keyboard.translation.a11y" = "Translation";
|
||||||
"keyboard.translation.a11yHint" = "Toggle translation or change the target language.";
|
"keyboard.translation.a11yHint" = "Toggle translation or change the target language.";
|
||||||
|
"keyboard.scenario.a11y" = "Polish scenario";
|
||||||
|
"keyboard.scenario.a11yHint" = "Choose how dictation is polished.";
|
||||||
|
|
||||||
/* Mode chip labels (used in both ext + preview stub) */
|
/* Mode chip labels (used in both ext + preview stub) */
|
||||||
"mode.off" = "Off";
|
"mode.off" = "Off";
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。";
|
"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。";
|
||||||
"settings.engine.cloud.title" = "云端识别与润色";
|
"settings.engine.cloud.title" = "云端识别与润色";
|
||||||
"settings.engine.cloud.subtitle" = "ASR 转录 + LLM 润色,需要 API Key。";
|
"settings.engine.cloud.subtitle" = "ASR 转录 + LLM 润色,需要 API Key。";
|
||||||
"settings.provider.title" = "提供商";
|
"settings.provider.title" = "云端引擎";
|
||||||
"settings.provider.subtitle" = "选择 LLM 提供商。";
|
"settings.provider.subtitle" = "选择 LLM 提供商。";
|
||||||
"settings.api.title" = "接口";
|
"settings.api.title" = "接口";
|
||||||
"settings.language.title" = "语言";
|
"settings.language.title" = "语言";
|
||||||
@@ -176,12 +176,16 @@
|
|||||||
"locale.chip.ja-JP" = "日";
|
"locale.chip.ja-JP" = "日";
|
||||||
"locale.chip.ko-KR" = "韩";
|
"locale.chip.ko-KR" = "韩";
|
||||||
|
|
||||||
/* 翻译 chip (v0.2.1) */
|
/* Translation chip (v0.3) */
|
||||||
"keyboard.translation.off" = "翻译";
|
"keyboard.translation.chip" = "翻译";
|
||||||
|
"keyboard.translation.offMenu" = "不翻译";
|
||||||
|
"keyboard.translation.off" = "不翻译";
|
||||||
"keyboard.translation.enable" = "开启翻译";
|
"keyboard.translation.enable" = "开启翻译";
|
||||||
"keyboard.translation.disable" = "关闭翻译";
|
"keyboard.translation.disable" = "关闭翻译";
|
||||||
"keyboard.translation.a11y" = "翻译";
|
"keyboard.translation.a11y" = "翻译";
|
||||||
"keyboard.translation.a11yHint" = "切换翻译开关或修改目标语言。";
|
"keyboard.translation.a11yHint" = "切换翻译开关或修改目标语言。";
|
||||||
|
"keyboard.scenario.a11y" = "润色场景";
|
||||||
|
"keyboard.scenario.a11yHint" = "选择润色风格或使用场景。";
|
||||||
|
|
||||||
/* Mode chip labels */
|
/* Mode chip labels */
|
||||||
"mode.off" = "关闭";
|
"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
|
// 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.
|
// "on" state during init, but new writes never touch the key.
|
||||||
static let translationTargetLocaleId = "config.translationTargetLocaleId"
|
static let translationTargetLocaleId = "config.translationTargetLocaleId"
|
||||||
|
static let polishScenarioId = "config.polishScenarioId"
|
||||||
}
|
}
|
||||||
|
|
||||||
@Published public var providerId: String {
|
@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
|
/// box. Users opt in from Settings when the iOS ASR output isn't
|
||||||
/// strong enough (noisy far-field audio, dialectal Chinese, etc.).
|
/// strong enough (noisy far-field audio, dialectal Chinese, etc.).
|
||||||
@Published public var localModeCloudPolishEnabled: Bool {
|
@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.
|
/// Host-app UI language. Also mirrored to the App Group for the keyboard extension.
|
||||||
@Published public var uiLanguage: AppUILanguage {
|
@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
|
/// extension can honour it (and so the chip on the keyboard reflects
|
||||||
/// the user's choice without a host-app round-trip).
|
/// the user's choice without a host-app round-trip).
|
||||||
@Published public var translationTargetLocaleId: String {
|
@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
|
/// Whether the pipeline should run translate-and-polish (not just
|
||||||
/// path now real (see `localModeProviderId`), `translationEnabled`
|
/// polish). Cloud engine: any selected target locale. Local engine:
|
||||||
/// alone is enough to decide whether the pipeline should translate.
|
/// only when cloud polish is also enabled.
|
||||||
/// 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.
|
|
||||||
public var isTranslationEffective: Bool {
|
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
|
/// Translation picker visibility. Cloud engine: always. Local engine:
|
||||||
/// now run the cloud translate-and-polish step (the local engine
|
/// only when "Cloud polish after ASR" is on — translation is a
|
||||||
/// routes through DeepSeek via `localModeProviderId`), so the row
|
/// sub-step of that cloud LLM pass, not a standalone feature.
|
||||||
/// is shown whenever an engine mode is selected.
|
|
||||||
public var isTranslationRowVisible: Bool {
|
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 {
|
public var isConfigured: Bool {
|
||||||
@@ -263,6 +288,17 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
|||||||
// conservative default that matches the picker / chip UX).
|
// conservative default that matches the picker / chip UX).
|
||||||
self.translationTargetLocaleId = resolvedDefaults.string(forKey: Key.translationTargetLocaleId)
|
self.translationTargetLocaleId = resolvedDefaults.string(forKey: Key.translationTargetLocaleId)
|
||||||
?? TranslationLanguageCatalog.offLocaleId
|
?? 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.
|
// Cloud no longer exposes off/transcribe; migrate legacy values.
|
||||||
if self.engineMode == "cloud", self.modeId != "polish" {
|
if self.engineMode == "cloud", self.modeId != "polish" {
|
||||||
@@ -313,6 +349,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
|||||||
apiKey = ""
|
apiKey = ""
|
||||||
model = preset.defaultModel
|
model = preset.defaultModel
|
||||||
systemPrompt = AppGroupStore.defaultSystemPrompt(for: "openai")
|
systemPrompt = AppGroupStore.defaultSystemPrompt(for: "openai")
|
||||||
|
polishScenarioId = PolishScenarioCatalog.defaultId
|
||||||
hasAcknowledgedCloudSharing = false
|
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
|
// the `translationEnabled` Bool accessor below is kept as a
|
||||||
// computed shim for source compatibility.
|
// computed shim for source compatibility.
|
||||||
static let translationTargetLocaleId = "config.translationTargetLocaleId"
|
static let translationTargetLocaleId = "config.translationTargetLocaleId"
|
||||||
|
static let polishScenarioId = "config.polishScenarioId"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Reads
|
// MARK: - Reads
|
||||||
@@ -127,6 +128,11 @@ public struct AppGroupStore: @unchecked Sendable {
|
|||||||
?? TranslationLanguageCatalog.offLocaleId
|
?? TranslationLanguageCatalog.offLocaleId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var polishScenarioId: String {
|
||||||
|
let stored = defaults.string(forKey: Key.polishScenarioId)
|
||||||
|
return PolishScenarioCatalog.resolve(stored ?? PolishScenarioCatalog.defaultId).id
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Writes
|
// MARK: - Writes
|
||||||
|
|
||||||
public func setModeId(_ id: String) {
|
public func setModeId(_ id: String) {
|
||||||
@@ -169,6 +175,70 @@ public struct AppGroupStore: @unchecked Sendable {
|
|||||||
/// round-trip.
|
/// round-trip.
|
||||||
public func setTranslationTargetLocaleId(_ id: String) {
|
public func setTranslationTargetLocaleId(_ id: String) {
|
||||||
defaults.set(id, forKey: Key.translationTargetLocaleId)
|
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
|
// 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.
|
/// `handler` on the main actor.
|
||||||
public final class FlowSessionDarwinObserver {
|
public final class FlowSessionDarwinObserver {
|
||||||
private final class Box: @unchecked Sendable {
|
private final class Box: @unchecked Sendable {
|
||||||
@@ -30,11 +30,16 @@ public final class FlowSessionDarwinObserver {
|
|||||||
|
|
||||||
private let box: Box
|
private let box: Box
|
||||||
private let token: UnsafeMutableRawPointer
|
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)
|
let box = Box(handler: handler)
|
||||||
self.box = box
|
self.box = box
|
||||||
self.token = Unmanaged.passRetained(box).toOpaque()
|
self.token = Unmanaged.passRetained(box).toOpaque()
|
||||||
|
self.notificationName = notificationName as CFString
|
||||||
|
|
||||||
CFNotificationCenterAddObserver(
|
CFNotificationCenterAddObserver(
|
||||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||||
@@ -44,7 +49,7 @@ public final class FlowSessionDarwinObserver {
|
|||||||
let box = Unmanaged<Box>.fromOpaque(observer).takeUnretainedValue()
|
let box = Unmanaged<Box>.fromOpaque(observer).takeUnretainedValue()
|
||||||
Task { @MainActor in box.handler() }
|
Task { @MainActor in box.handler() }
|
||||||
},
|
},
|
||||||
FlowSessionDarwin.notificationName as CFString,
|
self.notificationName,
|
||||||
nil,
|
nil,
|
||||||
.deliverImmediately
|
.deliverImmediately
|
||||||
)
|
)
|
||||||
@@ -54,7 +59,7 @@ public final class FlowSessionDarwinObserver {
|
|||||||
CFNotificationCenterRemoveObserver(
|
CFNotificationCenterRemoveObserver(
|
||||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||||
token,
|
token,
|
||||||
CFNotificationName(FlowSessionDarwin.notificationName as CFString),
|
CFNotificationName(notificationName),
|
||||||
nil
|
nil
|
||||||
)
|
)
|
||||||
Unmanaged<Box>.fromOpaque(token).release()
|
Unmanaged<Box>.fromOpaque(token).release()
|
||||||
|
|||||||
@@ -98,14 +98,29 @@ public final class KeyboardState: ObservableObject {
|
|||||||
/// Defaults to `offLocaleId` so the keyboard boots in the "off"
|
/// Defaults to `offLocaleId` so the keyboard boots in the "off"
|
||||||
/// state on first install.
|
/// state on first install.
|
||||||
@Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId
|
@Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId
|
||||||
/// v0.2.1: effective predicate — mirrors `ProviderConfig`.
|
/// Selected polish scenario mirrored from App Group.
|
||||||
/// v0.2.1 follow-up: no longer gates on `engineMode == "cloud"`
|
@Published public var polishScenarioId: String = PolishScenarioCatalog.defaultId
|
||||||
/// because the local engine's translate-and-polish step now runs
|
/// v0.2.0: mirrored from App Group — local engine runs the cloud
|
||||||
/// (routed through DeepSeek). Row visibility (`isTranslationRowVisible`
|
/// LLM step only when this is `true`.
|
||||||
/// on `ProviderConfig`) keeps the picker honest, so the keyboard
|
@Published public var localModeCloudPolishEnabled: Bool = false
|
||||||
/// can rely on `translationEnabled` alone here.
|
/// Whether translate-and-polish is actually armed for the current
|
||||||
|
/// engine (local requires cloud polish + a target locale).
|
||||||
public var isTranslationEffective: Bool {
|
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.
|
/// 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
|
/// is derived from the locale id, so there's no separate toggle to
|
||||||
/// persist. Wired in `KeyboardViewController.installStateActions`.
|
/// persist. Wired in `KeyboardViewController.installStateActions`.
|
||||||
public var setTranslationTargetLocaleId: (String) -> Void = { _ in }
|
public var setTranslationTargetLocaleId: (String) -> Void = { _ in }
|
||||||
|
public var setPolishScenarioId: (String) -> Void = { _ in }
|
||||||
public var insertNewline: () -> Void = {}
|
public var insertNewline: () -> Void = {}
|
||||||
public var insertSpace: () -> Void = {}
|
public var insertSpace: () -> Void = {}
|
||||||
public var deleteBackward: () -> Void = {}
|
public var deleteBackward: () -> Void = {}
|
||||||
|
|||||||
@@ -8,14 +8,12 @@
|
|||||||
// Engine matrix:
|
// Engine matrix:
|
||||||
// - `engineMode == "cloud"` → always polish (cloud engine's whole point).
|
// - `engineMode == "cloud"` → always polish (cloud engine's whole point).
|
||||||
// - `engineMode == "local"`,
|
// - `engineMode == "local"`,
|
||||||
// `localModeCloudPolishEnabled == false` → ASR-only, return raw.
|
// cloud polish disabled → ASR-only, return raw.
|
||||||
// - `engineMode == "local"`,
|
// - `engineMode == "local"`,
|
||||||
// `localModeCloudPolishEnabled == true` → polish via the user's LLM
|
// cloud polish enabled → DeepSeek LLM step (polish or translate).
|
||||||
// (DeepSeek by default). The local engine gains stronger accuracy on
|
// Translation uses `.translate` + `TranslationPrompt`; polish uses
|
||||||
// noisy / dialectal Chinese at the cost of one cloud round-trip.
|
// the default system prompt. Missing preconfigured DeepSeek key
|
||||||
// If the user hasn't entered an API key the call falls back to the
|
// throws `missingAPIKey` and callers deliver raw + warning.
|
||||||
// raw transcript and surfaces a warning so the keyboard can show
|
|
||||||
// the "fill in your key" hint.
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@@ -24,10 +22,8 @@ public actor PolishingService {
|
|||||||
public enum PolishError: Error, Equatable {
|
public enum PolishError: Error, Equatable {
|
||||||
case noTranscript
|
case noTranscript
|
||||||
case timeout
|
case timeout
|
||||||
/// v0.2.0: local engine + cloud-polish-on, but the user hasn't
|
/// Local engine DeepSeek step: `PreconfiguredKeys.deepseek` is
|
||||||
/// saved an API key in the Keychain. Caller surfaces an Alert
|
/// still the repo placeholder, or cloud engine Keychain is empty.
|
||||||
/// telling them to fill it in; we deliver the raw transcript
|
|
||||||
/// so no data is lost.
|
|
||||||
case missingAPIKey
|
case missingAPIKey
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,16 +70,10 @@ public actor PolishingService {
|
|||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { throw PolishError.noTranscript }
|
guard !trimmed.isEmpty else { throw PolishError.noTranscript }
|
||||||
|
|
||||||
// Local engine: ASR-only unless the user opted into cloud
|
// Local engine: ASR-only unless cloud polish is enabled
|
||||||
// polish via `localModeCloudPolishEnabled`. The cloud polish
|
// (translation is a sub-option of that LLM step).
|
||||||
// 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.
|
|
||||||
if store.engineMode == "local" {
|
if store.engineMode == "local" {
|
||||||
guard store.localModeCloudPolishEnabled else { return trimmed }
|
guard store.shouldRunCloudLLMStep else { return trimmed }
|
||||||
guard !store.apiKey.isEmpty else {
|
|
||||||
throw PolishError.missingAPIKey
|
|
||||||
}
|
|
||||||
return try await polishRemote(
|
return try await polishRemote(
|
||||||
trimmed,
|
trimmed,
|
||||||
mode: mode,
|
mode: mode,
|
||||||
@@ -117,12 +107,11 @@ public actor PolishingService {
|
|||||||
client = injectedClient
|
client = injectedClient
|
||||||
} else {
|
} else {
|
||||||
let preset = LLMProvider.provider(id: effectiveProviderId)
|
let preset = LLMProvider.provider(id: effectiveProviderId)
|
||||||
let baseURL = store.baseURL.isEmpty ? preset.defaultBaseURL : store.baseURL
|
let (baseURL, model) = Self.resolveLLMEndpoint(
|
||||||
// Pre-existing typo fix: the user-overridden `store.model`
|
store: store,
|
||||||
// path was returning `preset.defaultModel` on both branches,
|
preset: preset,
|
||||||
// silently ignoring the user's custom model field. Restore
|
providerIdOverride: providerIdOverride
|
||||||
// the asymmetry so the user override actually wins.
|
)
|
||||||
let model = store.model.isEmpty ? preset.defaultModel : store.model
|
|
||||||
let apiKey: String
|
let apiKey: String
|
||||||
if effectiveProviderId == "deepseek" {
|
if effectiveProviderId == "deepseek" {
|
||||||
let preconfigured = PreconfiguredKeys.deepseek
|
let preconfigured = PreconfiguredKeys.deepseek
|
||||||
@@ -138,7 +127,11 @@ public actor PolishingService {
|
|||||||
}
|
}
|
||||||
client = OpenAICompatibleClient(baseURL: baseURL, apiKey: apiKey, model: model)
|
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)
|
let budget = effectiveTimeout(for: trimmed)
|
||||||
|
|
||||||
return try await withThrowingTaskGroup(of: String.self) { group in
|
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
|
/// existing `store.systemPrompt` behaviour so every other call site
|
||||||
/// is byte-identical to before. An explicit `override` wins over
|
/// is byte-identical to before. An explicit `override` wins over
|
||||||
/// both paths so callers (and tests) can pin a specific prompt.
|
/// 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 {
|
if let override, !override.isEmpty {
|
||||||
return override
|
return override
|
||||||
}
|
}
|
||||||
switch mode {
|
switch mode {
|
||||||
case .polish:
|
case .polish:
|
||||||
return store.systemPrompt
|
return store.resolvedPolishSystemPrompt(providerId: providerId)
|
||||||
case .translate(let targetLocaleId):
|
case .translate(let targetLocaleId):
|
||||||
let target = TranslationLanguageCatalog.resolve(targetLocaleId)
|
let target = TranslationLanguageCatalog.resolve(targetLocaleId)
|
||||||
let pid = store.providerId
|
let pid = providerId ?? store.providerId
|
||||||
return TranslationPrompt.make(target: target, providerId: pid)
|
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
|
let scaled = timeout + (Double(text.count) / 200.0) * 2.0
|
||||||
return min(max(scaled, timeout), 120)
|
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
|
/// Preconfigured DeepSeek API key. Replace `placeholder` with a
|
||||||
/// real key in `Sources/.../PreconfiguredKeys.swift` before
|
/// real key in `Sources/.../PreconfiguredKeys.swift` before
|
||||||
/// distributing a build.
|
/// distributing a build.
|
||||||
public static let deepseek: String = placeholder
|
public static let deepseek: String = "REMOVED_LEAKED_DEEPSEEK_KEY"
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
/// Forces a lazy init at app launch in DEBUG builds so the assert
|
/// Forces a lazy init at app launch in DEBUG builds so the assert
|
||||||
|
|||||||
@@ -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
|
// The "translate AND polish" blend is intentional: ASR transcripts are
|
||||||
// noisy (homophone errors, broken segmentation, dropped particles), so
|
// noisy (homophone errors, broken segmentation, dropped particles), so
|
||||||
// the prompt asks the model to clean the noise while translating.
|
// the prompt asks the model to clean the noise while translating.
|
||||||
// Keeping those two concerns in one prompt matches how our existing
|
// Scenario output format (`ScenarioStyleDirective`) is appended so
|
||||||
// polish prompt already mixes "preserve meaning" with "fix punctuation /
|
// translate-and-polish honours the user's polish scenario choice.
|
||||||
// drop filler".
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@@ -26,36 +25,53 @@ public enum TranslationPrompt {
|
|||||||
/// - target: target language entry resolved via `TranslationLanguageCatalog`.
|
/// - target: target language entry resolved via `TranslationLanguageCatalog`.
|
||||||
/// - providerId: provider preset id (e.g. `"deepseek"`, `"openai"`);
|
/// - providerId: provider preset id (e.g. `"deepseek"`, `"openai"`);
|
||||||
/// drives the language the prompt is written in.
|
/// 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)
|
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)
|
// 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 转写了一段可能含噪声的口述:
|
你是一位语音输入翻译与润色助手。用户用 ASR 转写了一段可能含噪声的口述:
|
||||||
1) 先识别原话的主要语言(若不确定则按用户给定的方向处理);
|
1) 先识别原话的主要语言(若不确定则按用户给定的方向处理);
|
||||||
2) 将内容翻译为「\(target.promptLanguageName)」,保留原意,不增删事实、不臆测;
|
2) 将内容翻译为「\(target.promptLanguageName)」,保留原意,不增删事实、不臆测;
|
||||||
3) 顺带修复 ASR 噪声(同音错字、漏字、断句错乱),让译文读起来自然;
|
3) 顺带修复 ASR 噪声(同音错字、漏字、断句错乱),让译文读起来自然;
|
||||||
4) 保留枚举结构(第一…第二…),使用「\(target.promptLanguageName)」的列表惯例;
|
4) 简洁;若下方场景未要求列表/分段,不超过原文 1.5 倍;去掉无意义的口头禅(嗯、啊、那个);
|
||||||
5) 简洁,不超过原文 1.5 倍;去掉无意义的口头禅(嗯、啊、那个);
|
5) 若场景格式要求列表或分段,允许按格式组织译文;总长度不超过原长 2 倍;
|
||||||
6) 只输出译文正文,不要解释、不要加引号、不要前缀"以下是翻译"。
|
6) 只输出译文正文,不要解释、不要加引号、不要前缀"以下是翻译"。
|
||||||
|
\(directive)
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - English prompt (for OpenAI / OpenAI-compatible non-Chinese)
|
// 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:
|
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);
|
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;
|
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;
|
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;
|
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) Keep it concise — no longer than 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.
|
6) Output ONLY the translation. No quotes, no preamble, no explanation.
|
||||||
|
\(directive)
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"engine.asr.appleSpeech" = "Apple SpeechAnalyzer";
|
"engine.asr.appleSpeech" = "Apple SpeechAnalyzer";
|
||||||
|
|
||||||
/* v0.2.0: flow-level warnings surfaced alongside the final transcript. */
|
/* 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 */
|
/* LLM providers */
|
||||||
"provider.openai" = "OpenAI";
|
"provider.openai" = "OpenAI";
|
||||||
@@ -30,3 +30,21 @@
|
|||||||
"error.asr.formatUnsupported" = "This device does not support the required audio format.";
|
"error.asr.formatUnsupported" = "This device does not support the required audio format.";
|
||||||
"error.asr.noSpeech" = "No speech detected. Please try again.";
|
"error.asr.noSpeech" = "No speech detected. Please try again.";
|
||||||
"error.asr.chunkFailed" = "Segment %lld failed: %@";
|
"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 语音识别";
|
"engine.asr.appleSpeech" = "Apple 语音识别";
|
||||||
|
|
||||||
/* v0.2.0: flow-level warnings surfaced alongside the final transcript. */
|
/* 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 */
|
/* LLM providers */
|
||||||
"provider.openai" = "OpenAI";
|
"provider.openai" = "OpenAI";
|
||||||
@@ -30,3 +30,21 @@
|
|||||||
"error.asr.formatUnsupported" = "当前设备不支持该语音输入格式。";
|
"error.asr.formatUnsupported" = "当前设备不支持该语音输入格式。";
|
||||||
"error.asr.noSpeech" = "未识别到语音内容,请重试。";
|
"error.asr.noSpeech" = "未识别到语音内容,请重试。";
|
||||||
"error.asr.chunkFailed" = "第 %lld 段识别失败:%@";
|
"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()
|
let calls = await counter.value()
|
||||||
XCTAssertEqual(calls, 0)
|
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
|
// MARK: - Test helpers
|
||||||
|
|||||||
+2
-2
@@ -39,8 +39,8 @@ settings:
|
|||||||
GENERATE_INFOPLIST_FILE: NO
|
GENERATE_INFOPLIST_FILE: NO
|
||||||
ENABLE_MODULE_VERIFIER: YES
|
ENABLE_MODULE_VERIFIER: YES
|
||||||
CLANG_CXX_LANGUAGE_STANDARD: c++17
|
CLANG_CXX_LANGUAGE_STANDARD: c++17
|
||||||
MARKETING_VERSION: "0.2.0"
|
MARKETING_VERSION: "0.3.0"
|
||||||
CURRENT_PROJECT_VERSION: "4"
|
CURRENT_PROJECT_VERSION: "5"
|
||||||
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
||||||
|
|
||||||
# 项目级签名 xcconfig,适用于所有 target
|
# 项目级签名 xcconfig,适用于所有 target
|
||||||
|
|||||||
Reference in New Issue
Block a user