From a8d58d8f0c7691653b0d8390fc86a853c586a0c5 Mon Sep 17 00:00:00 2001
From: Rocky <72559939+hkgood@users.noreply.github.com>
Date: Thu, 6 Aug 2026 15:11:12 +0800
Subject: [PATCH] feat(polish): allow mood emoji on custom styles and ship
Flow/ASR fixes
Custom polish styles can opt in to emotion-matched emoji (default off), with
prompt-level opt-in detection so paste-only styles keep model-added emoji.
Also include Volcengine API-Key ASR auth, voice-processing capture, PiP flash
fix, and related keyboard Shift/haptics reliability work.
---
CHANGELOG.md | 20 +++
.../AppIcon.icon/Assets/App Icon Template.svg | 22 ---
OSGKeyboard/AppIcon.icon/Assets/Group 29.svg | 58 ++++++++
OSGKeyboard/AppIcon.icon/icon.json | 7 +-
OSGKeyboard/Services/FlowSessionManager.swift | 9 ++
OSGKeyboard/Views/ASRSettingsCard.swift | 112 ++++++++++------
OSGKeyboard/Views/PolishStylesView.swift | 20 ++-
OSGKeyboard/en.lproj/Localizable.strings | 9 +-
OSGKeyboard/zh-Hans.lproj/Localizable.strings | 9 +-
OSGKeyboardExt/KeyboardViewController.swift | 3 +
.../Services/KeyboardFlowCoordinator.swift | 31 ++++-
OSGKeyboardExt/Typing/TypingRootView.swift | 10 +-
OSGKeyboardExt/Views/KeyboardRootView.swift | 15 ++-
.../Views/KeyboardTopControls.swift | 5 +-
.../Views/ToolbarActionButtons.swift | 35 ++++-
OSGKeyboardExtTests/EnglishTypingTests.swift | 58 ++++++++
.../KeyboardSurfaceStateTests.swift | 116 ++++++++++++++++
.../CloudASR/VolcengineCloudASRClient.swift | 58 +-------
.../FlowAudioSessionCoordinator.swift | 10 +-
.../Services/FlowCaptureVoiceProcessing.swift | 108 +++++++++++++++
.../Services/FlowContinuousCapture.swift | 25 +++-
.../Services/LiveDictationController.swift | 39 +++---
OSGKeyboardMac/MacPolishStylesView.swift | 24 +++-
OSGKeyboardMac/MacSettingsView.swift | 91 ++++++++-----
.../Models/AppGroupConfiguration.swift | 8 +-
OSGKeyboardShared/Models/LLMProvider.swift | 2 +-
.../Models/PolishStylePack.swift | 47 +++++++
OSGKeyboardShared/Models/ProviderConfig.swift | 9 +-
.../Models/VolcengineASRFields.swift | 126 +++++++++++++++---
.../Services/FlowKeyboardPolicies.swift | 35 ++++-
.../Services/FlowSessionBridge.swift | 41 +++++-
.../Services/FlowSessionKeys.swift | 9 ++
.../Services/PolishPromptComposer.swift | 33 +++++
.../Services/PolishingService.swift | 10 +-
.../Services/TranscriptPostProcessor.swift | 22 ++-
.../Typing/TypingAutocapitalization.swift | 14 +-
.../Typing/TypingSessionController.swift | 94 ++++++++++++-
OSGKeyboardShared/en.lproj/Shared.strings | 9 +-
.../zh-Hans.lproj/Shared.strings | 9 +-
OSGKeyboardTests/CloudASRTests.swift | 69 +++++++++-
.../FlowKeyboardPoliciesTests.swift | 84 ++++++++++++
OSGKeyboardTests/FlowReliabilityTests.swift | 10 ++
OSGKeyboardTests/FlowSessionBridgeTests.swift | 35 +++++
OSGKeyboardTests/IntelligentPolishTests.swift | 72 ++++++++++
OSGKeyboardTests/PolishStylePackTests.swift | 84 ++++++++++++
docs/keyboard-memory-budget.md | 5 +-
project.yml | 4 +-
47 files changed, 1467 insertions(+), 258 deletions(-)
delete mode 100644 OSGKeyboard/AppIcon.icon/Assets/App Icon Template.svg
create mode 100644 OSGKeyboard/AppIcon.icon/Assets/Group 29.svg
create mode 100644 OSGKeyboardHostSupport/Services/FlowCaptureVoiceProcessing.swift
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a50b583..62f66aa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+- **Volcengine ASR API Key auth**: settings can switch to the new-console single `X-Api-Key` mode while keeping legacy APP ID + Access Token as the default; SAUC resource is fixed to Doubao streaming 2.0 (`volc.seedasr.sauc.duration`). / **火山 ASR API Key 鉴权**:设置可切换到新控制台单字段 `X-Api-Key`,默认仍为旧版 APP ID + Access Token;SAUC 资源固定为豆包流式 2.0(`volc.seedasr.sauc.duration`)。
+- **Custom style mood emoji**: custom polish styles can opt in (default off) to allow emotion-matched emoji; the prompt overrides R5 and post-processing keeps them on screen. Paste-only prompts that declare emoji opt-in are detected automatically. / **自定义风格情绪 emoji**:自定义润色风格可单独开启(默认关)按情绪点缀 emoji;提示词覆盖 R5,后处理保留上屏。仅粘贴声明允许 emoji 的 prompt 也会自动识别。
+
+### Fixed
+- **PiP post-utterance yellow flash**: after a voice turn, the mic no longer briefly shows yellow「正在启动画中画」— hold ready once the session proved live, refresh host ready on ack, and avoid labeling an already-active PiP as `.starting`. / **PiP 句末黄色闪烁**:语音说完后麦克风不再短暂变黄并提示「正在启动画中画」——会话曾就绪后保持绿灯、ack 后立即刷新 host ready,且已激活的 PiP 不再标成 `.starting`。
+- **English Shift after Return**: sentence autocapitalization treats newline as a new-line boundary (system keyboard behavior), so Notes-style multi-line Return arms Shift again. / **回车后英文 Shift**:句首自动大写将换行视为新行边界(对齐系统键盘),备忘录等多行回车后会重新点亮 Shift。
+- **English Shift with stale proxy**: after our own insert/delete, autocap merges a local caret-prefix shadow when `documentContextBeforeInput` lags (e.g. period / Return in Notes). / **滞后前文下的英文 Shift**:自身插删后若 `documentContextBeforeInput` 滞后,自动大写会合并本地光标前文影子(如备忘录中的句号/回车)。
+
+## [1.6.2] - 2026-08-06
+
+### Changed
+- **ASR capture voice processing**: iOS App Flow / preview capture switches from `.measurement` to `.voiceChat`, enables `AVAudioEngine` Voice Processing, and prefers a near-talk built-in mic pattern so competing talkers in the same room are suppressed more strongly (Control Center Voice Isolation remains available once VP is on). / **ASR 采音人声处理**:iOS App 的 Flow / 预览采音从 `.measurement` 改为 `.voiceChat`,打开 `AVAudioEngine` Voice Processing,并优先近讲内置麦指向,以更好压制同房间旁人说话(开启 VP 后控制中心仍可选人声隔离)。
+
+### Fixed
+- **Voice toolbar haptics**: delete / space / return on the voice keyboard follow Settings → General → Haptics (mic unchanged). / **语音底栏震动**:语音键盘的删除 / 空格 / 回车跟随设置 → 通用 → 震动(麦克风大圆钮不变)。
+- **Haptics after app switch**: re-prepare Taptic when the keyboard appears so feedback does not go cold after switching host apps. / **切 App 后震动消失**:键盘每次出现时重新预热触觉引擎,避免从 A 切到 B 后触感变冷。
+- **Typing switch after cold start**: a sticky App Group `hostHeavy` flag no longer silently blocks 中文/EN when the host died mid-warmup; the flag now expires and is cleared on Flow state reset / host relaunch. / **冷启动后无法切拼音/英文**:宿主中途被杀留下的 `hostHeavy` 不再静默挡住中文/EN;该标志会过期,并在 Flow 状态清理与宿主重启时清除。
+- **Chinese Shift uppercase**: with Shift / Caps Lock / Shift-hold on in Pinyin mode, letter keys insert Latin uppercase directly (system-keyboard style) instead of sending uppercase keycodes to Rime, which rejects them. / **拼音 Shift 大写**:拼音模式下开启 Shift / Caps Lock / 按住 Shift 时,字母键直接插入大写拉丁字母(对齐系统键盘),不再把大写键码送给会拒绝的 Rime。
+
## [1.6.1] - 2026-08-05
### Added
diff --git a/OSGKeyboard/AppIcon.icon/Assets/App Icon Template.svg b/OSGKeyboard/AppIcon.icon/Assets/App Icon Template.svg
deleted file mode 100644
index afa301d..0000000
--- a/OSGKeyboard/AppIcon.icon/Assets/App Icon Template.svg
+++ /dev/null
@@ -1,22 +0,0 @@
-
diff --git a/OSGKeyboard/AppIcon.icon/Assets/Group 29.svg b/OSGKeyboard/AppIcon.icon/Assets/Group 29.svg
new file mode 100644
index 0000000..ee97722
--- /dev/null
+++ b/OSGKeyboard/AppIcon.icon/Assets/Group 29.svg
@@ -0,0 +1,58 @@
+
diff --git a/OSGKeyboard/AppIcon.icon/icon.json b/OSGKeyboard/AppIcon.icon/icon.json
index 7a85758..a30ebb2 100644
--- a/OSGKeyboard/AppIcon.icon/icon.json
+++ b/OSGKeyboard/AppIcon.icon/icon.json
@@ -8,11 +8,8 @@
"hidden" : false,
"layers" : [
{
- "fill" : "none",
- "hidden" : false,
- "image-name" : "App Icon Template.svg",
- "name" : "App Icon Template",
- "opacity" : 1
+ "image-name" : "Group 29.svg",
+ "name" : "Group 29"
}
],
"lighting" : "individual",
diff --git a/OSGKeyboard/Services/FlowSessionManager.swift b/OSGKeyboard/Services/FlowSessionManager.swift
index d0b6bbe..e10e44e 100644
--- a/OSGKeyboard/Services/FlowSessionManager.swift
+++ b/OSGKeyboard/Services/FlowSessionManager.swift
@@ -787,6 +787,11 @@ final class FlowSessionManager: ObservableObject {
reason = .audioEngineNotLive
} else if !usesPiPKeepAlive, !hasRecentAudio {
reason = .waitingForAudioProof
+ } else if usesPiPKeepAlive, pipController.isPictureInPictureActive {
+ // PiP is already up — transient gates (!polling / interruption)
+ // are not a cold start. Prefer awaitingDelivery-adjacent idle over
+ // `.starting` so the keyboard never flashes「正在启动画中画」.
+ reason = .awaitingDelivery
} else {
reason = .starting
}
@@ -1331,6 +1336,10 @@ final class FlowSessionManager: ObservableObject {
return
}
FlowSessionBridge.clearResult()
+ // Ack clears the terminal result; republish ready immediately so the
+ // keyboard does not linger on awaitingDelivery / starting between the
+ // 500 ms poll and the next heartbeat.
+ refreshHostReady()
}
private func hasUnacknowledgedTerminalResult() -> Bool {
diff --git a/OSGKeyboard/Views/ASRSettingsCard.swift b/OSGKeyboard/Views/ASRSettingsCard.swift
index 9a5fc1e..4a6c1b5 100644
--- a/OSGKeyboard/Views/ASRSettingsCard.swift
+++ b/OSGKeyboard/Views/ASRSettingsCard.swift
@@ -58,41 +58,62 @@ struct ASRSettingsCard: View {
private var volcengineRows: some View {
Group {
- SettingsCredentialRow(
- title: AppL10n.string("settings.asr.volcengine.appId"),
- placeholder: "APP ID",
- text: Binding(
- get: { volcengineFields.appID },
- set: { updateVolcengine(appID: $0) }
- ),
- isSecret: true,
- isMonospaced: true
- )
+ Toggle(isOn: volcengineAPIKeyModeBinding) {
+ VStack(alignment: .leading, spacing: Spacing.xxs) {
+ Text("settings.asr.volcengine.apiKeyMode.title")
+ .font(TypeStyle.body)
+ .foregroundStyle(palette.textPrimary)
+ Text("settings.asr.volcengine.apiKeyMode.subtitle")
+ .font(TypeStyle.caption2)
+ .foregroundStyle(palette.textTertiary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ .tint(palette.accent)
+ .settingsListRow()
+
rowDivider
- SettingsCredentialRow(
- title: AppL10n.string("settings.asr.volcengine.accessToken"),
- placeholder: "Access Token",
- text: Binding(
- get: { volcengineFields.accessToken },
- set: { updateVolcengine(accessToken: $0) }
- ),
- isSecret: true,
- isMonospaced: true
- )
- rowDivider
- SettingsCredentialRow(
- title: AppL10n.string("settings.asr.volcengine.resourceId"),
- placeholder: CloudASRModelCatalog.defaultModel(for: "volcengine"),
- text: Binding(
- get: { volcengineFields.resourceID },
- set: { updateVolcengine(resourceID: $0) }
- ),
- isMonospaced: true,
- defaultValue: CloudASRModelCatalog.defaultModel(for: "volcengine")
- )
+
+ if volcengineFields.usesAPIKeyAuth {
+ SettingsCredentialRow(
+ title: AppL10n.string("settings.asr.volcengine.apiKey"),
+ placeholder: "API Key",
+ text: Binding(
+ get: { volcengineFields.apiKeyCredential },
+ set: { updateVolcengine(apiKeyCredential: $0) }
+ ),
+ isSecret: true,
+ isMonospaced: true
+ )
+ } else {
+ SettingsCredentialRow(
+ title: AppL10n.string("settings.asr.volcengine.appId"),
+ placeholder: "APP ID",
+ text: Binding(
+ get: { volcengineFields.appID },
+ set: { updateVolcengine(appID: $0) }
+ ),
+ isSecret: true,
+ isMonospaced: true
+ )
+ rowDivider
+ SettingsCredentialRow(
+ title: AppL10n.string("settings.asr.volcengine.accessToken"),
+ placeholder: "Access Token",
+ text: Binding(
+ get: { volcengineFields.accessToken },
+ set: { updateVolcengine(accessToken: $0) }
+ ),
+ isSecret: true,
+ isMonospaced: true
+ )
+ }
+
rowDivider
SettingsProviderRow(title: AppL10n.string("settings.provider.note")) {
- Text("settings.asr.volcengine.note")
+ Text(volcengineFields.usesAPIKeyAuth
+ ? "settings.asr.volcengine.note.apiKey"
+ : "settings.asr.volcengine.note.appToken")
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.fixedSize(horizontal: false, vertical: true)
@@ -105,22 +126,29 @@ struct ASRSettingsCard: View {
}
private var volcengineFields: VolcengineASRFields {
- VolcengineASRFields.parse(
- apiKey: config.asrApiKey,
- resourceFallback: config.asrModel.isEmpty
- ? CloudASRModelCatalog.defaultModel(for: "volcengine")
- : config.asrModel
+ VolcengineASRFields.parse(apiKey: config.asrApiKey)
+ }
+
+ private var volcengineAPIKeyModeBinding: Binding {
+ Binding(
+ get: { volcengineFields.usesAPIKeyAuth },
+ set: { updateVolcengine(authMode: $0 ? .apiKey : .appToken) }
)
}
- private func updateVolcengine(appID: String? = nil, accessToken: String? = nil, resourceID: String? = nil) {
+ private func updateVolcengine(
+ authMode: VolcengineASRAuthMode? = nil,
+ appID: String? = nil,
+ accessToken: String? = nil,
+ apiKeyCredential: String? = nil
+ ) {
var fields = volcengineFields
+ if let authMode { fields.authMode = authMode }
if let appID { fields.appID = appID }
if let accessToken { fields.accessToken = accessToken }
- if let resourceID {
- fields.resourceID = resourceID
- config.asrModel = resourceID
- }
+ if let apiKeyCredential { fields.apiKeyCredential = apiKeyCredential }
+ // Keep store model aligned with the locked SAUC 2.0 resource.
+ config.asrModel = VolcengineASRFields.fixedResourceID
config.asrApiKey = fields.encodedAPIKey
}
diff --git a/OSGKeyboard/Views/PolishStylesView.swift b/OSGKeyboard/Views/PolishStylesView.swift
index 5a49bd7..b012315 100644
--- a/OSGKeyboard/Views/PolishStylesView.swift
+++ b/OSGKeyboard/Views/PolishStylesView.swift
@@ -239,7 +239,8 @@ struct PolishStylesView: View {
format: AppL10n.string("polishStyles.copyName"),
pack.displayName(language: config.uiLanguage)
),
- prompt: pack.prompt
+ prompt: pack.prompt,
+ allowsAddedEmoji: pack.allowsAddedEmoji
)
showEditor = true
}
@@ -310,12 +311,14 @@ private struct PolishStyleEditorSheet: View {
@Environment(\.themePalette) private var palette
@State private var name: String
@State private var prompt: String
+ @State private var allowsAddedEmoji: Bool
init(pack: PolishStylePack?, onSave: @escaping (PolishStylePack) -> Void) {
self.pack = pack
self.onSave = onSave
_name = State(initialValue: pack?.name ?? "")
_prompt = State(initialValue: pack?.prompt ?? PolishStylePackCatalog.newUserPromptTemplate)
+ _allowsAddedEmoji = State(initialValue: pack?.allowsAddedEmoji ?? false)
}
var body: some View {
@@ -324,10 +327,23 @@ private struct PolishStyleEditorSheet: View {
Section("polishStyles.editor.name") {
TextField("polishStyles.editor.namePlaceholder", text: $name)
}
+ Section {
+ Toggle("polishStyles.editor.allowsAddedEmoji", isOn: $allowsAddedEmoji)
+ } footer: {
+ Text("polishStyles.editor.allowsAddedEmoji.hint")
+ }
Section {
TextEditor(text: $prompt)
.font(.body.monospaced())
.frame(minHeight: 320)
+ .onChange(of: prompt) { _, newValue in
+ // Paste-only custom prompts that declare emoji opt-in
+ // should flip the toggle so post-processing keeps them.
+ if !allowsAddedEmoji,
+ PolishStylePack.promptDeclaresAddedEmojiOptIn(newValue) {
+ allowsAddedEmoji = true
+ }
+ }
} header: {
HStack {
Text("polishStyles.editor.prompt")
@@ -355,6 +371,8 @@ private struct PolishStyleEditorSheet: View {
id: pack?.id ?? "user.\(UUID().uuidString.lowercased())",
name: name,
prompt: prompt,
+ allowsAddedEmoji: allowsAddedEmoji
+ || PolishStylePack.promptDeclaresAddedEmojiOptIn(prompt),
kind: .user,
createdAt: pack?.createdAt ?? Date()
)
diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings
index 8400854..7c46919 100644
--- a/OSGKeyboard/en.lproj/Localizable.strings
+++ b/OSGKeyboard/en.lproj/Localizable.strings
@@ -137,8 +137,11 @@
"settings.provider.note" = "Note";
"settings.asr.volcengine.appId" = "APP ID";
"settings.asr.volcengine.accessToken" = "Access Token";
-"settings.asr.volcengine.resourceId" = "Resource ID";
-"settings.asr.volcengine.note" = "Secret Key is not required. Resource ID defaults to volc.seedasr.sauc.duration.";
+"settings.asr.volcengine.apiKey" = "API Key";
+"settings.asr.volcengine.apiKeyMode.title" = "Use new API Key auth";
+"settings.asr.volcengine.apiKeyMode.subtitle" = "Turn on for the new Volcengine console. Keep off if you already use APP ID + Access Token.";
+"settings.asr.volcengine.note.appToken" = "Legacy console: enter APP ID and Access Token. Secret Key is not required. Resource is fixed to Doubao streaming 2.0 (volc.seedasr.sauc.duration).";
+"settings.asr.volcengine.note.apiKey" = "New console: enter only the API Key. Resource is fixed to Doubao streaming 2.0 (volc.seedasr.sauc.duration).";
"provider.openai" = "OpenAI";
"provider.deepseek" = "DeepSeek";
"provider.qwen" = "Qwen (DashScope)";
@@ -404,6 +407,8 @@
"polishStyles.copyName" = "%@ Copy";
"polishStyles.editor.name" = "Name";
"polishStyles.editor.namePlaceholder" = "Style name";
+"polishStyles.editor.allowsAddedEmoji" = "Allow mood emojis";
+"polishStyles.editor.allowsAddedEmoji.hint" = "When on, polish may add a few emojis that match the draft’s emotion, and keeps them on screen. Off by default.";
"polishStyles.editor.prompt" = "Complete prompt";
"polishStyles.editor.hint" = "Describe only the role, tone, style boundaries, and examples. The system appends the personal dictionary, safety contract, and output rules.";
"polishStyles.error.title" = "Couldn’t save style";
diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings
index f7af6bb..66b6c23 100644
--- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings
+++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings
@@ -137,8 +137,11 @@
"settings.provider.note" = "提示";
"settings.asr.volcengine.appId" = "APP ID";
"settings.asr.volcengine.accessToken" = "Access Token";
-"settings.asr.volcengine.resourceId" = "Resource ID";
-"settings.asr.volcengine.note" = "Secret Key 当前无需填写。Resource ID 默认使用 volc.seedasr.sauc.duration。";
+"settings.asr.volcengine.apiKey" = "API Key";
+"settings.asr.volcengine.apiKeyMode.title" = "使用新版 API Key 鉴权";
+"settings.asr.volcengine.apiKeyMode.subtitle" = "新控制台请打开此开关;已有 AppID + Token 可保持关闭。";
+"settings.asr.volcengine.note.appToken" = "旧版控制台:填写 APP ID 与 Access Token。Secret Key 无需填写。识别资源固定为豆包流式 2.0(volc.seedasr.sauc.duration)。";
+"settings.asr.volcengine.note.apiKey" = "新版控制台:只需填写 API Key。识别资源固定为豆包流式 2.0(volc.seedasr.sauc.duration)。";
"provider.openai" = "OpenAI";
"provider.deepseek" = "DeepSeek";
"provider.qwen" = "通义千问";
@@ -403,6 +406,8 @@
"polishStyles.copyName" = "%@副本";
"polishStyles.editor.name" = "名称";
"polishStyles.editor.namePlaceholder" = "风格名称";
+"polishStyles.editor.allowsAddedEmoji" = "允许按情绪添加 emoji";
+"polishStyles.editor.allowsAddedEmoji.hint" = "开启后,润色可按原文情绪点缀少量 emoji,并保留上屏。默认关闭。";
"polishStyles.editor.prompt" = "完整提示词";
"polishStyles.editor.hint" = "只需描述角色、语气、风格边界和示例。系统会自动追加个人词典、安全边界和输出契约。";
"polishStyles.error.title" = "无法保存风格";
diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift
index f694d29..802e12a 100644
--- a/OSGKeyboardExt/KeyboardViewController.swift
+++ b/OSGKeyboardExt/KeyboardViewController.swift
@@ -165,6 +165,9 @@ public final class KeyboardViewController: UIInputViewController {
configSync.refreshConfigFromAppGroup()
// Settings may have changed while the extension stayed alive.
applyPreferredSurfaceOnOpen()
+ // Re-warm Taptic after host app switches: SwiftUI `onAppear` often
+ // skips when the extension process is reused, leaving generators cold.
+ KeyboardHapticFeedback.prepare()
if state.surface == .typing {
OSGDiag.log("KVC.viewWillAppear enterTypingMode", category: "boot")
typingSession.enterTypingMode()
diff --git a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift
index e0c2a0c..a0f6dfd 100644
--- a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift
+++ b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift
@@ -41,6 +41,9 @@ final class KeyboardFlowCoordinator {
/// stale sample never flashes the mic orange while the session is healthy.
private var lastHostReadyAt: TimeInterval = 0
private static let hostReadyGrace: TimeInterval = 4
+ /// Once the host has published ready for this session, hold green through
+ /// brief inter-utterance ready flaps instead of flashing preparingSession.
+ private var sessionProvenReady = false
private var flowSessionMonitorTask: Task?
private var isAwaitingFlowResult = false
private var activeSessionId: UUID?
@@ -168,9 +171,17 @@ final class KeyboardFlowCoordinator {
// show red/white instead of a fake orange "starting" state.
adoptHostBusyStateIfNeeded(snapshot: readySnapshot)
- let hostReady = readySnapshot?.ready == true && FlowSessionBridge.isHostReady()
+ let hostReadyRaw = readySnapshot?.ready == true && FlowSessionBridge.isHostReady()
+ let sessionActive = FlowSessionBridge.isSessionActive()
let now = Date().timeIntervalSince1970
- if hostReady { lastHostReadyAt = now }
+ if hostReadyRaw {
+ lastHostReadyAt = now
+ sessionProvenReady = true
+ }
+ if !sessionActive {
+ sessionProvenReady = false
+ lastHostReadyAt = 0
+ }
// Grace window: the host was ready very recently, so treat a momentary
// stale heartbeat read as "still warming" rather than an outright
// failure. `isSessionActive` is heartbeat-independent, so it stays true
@@ -181,19 +192,30 @@ final class KeyboardFlowCoordinator {
// it as preparingSession was the orange-stuck bug after cold start:
// host utt.rec=1 → ready=false → keyboard forever "正在启动…".
let hostBusy = FlowKeyboardHostWarming.isHostBusy(reason: readySnapshot?.reason)
+ // Hold green after the session already proved ready — PiP mic release /
+ // ack lag must not flash yellow「正在启动画中画».
+ let holdReady = FlowKeyboardHostWarming.shouldHoldReady(
+ hostReady: hostReadyRaw,
+ hostBusy: hostBusy,
+ sessionActive: sessionActive,
+ sessionProvenReady: sessionProvenReady,
+ isPendingFlowStart: isPendingFlowStart,
+ snapshotReason: readySnapshot?.reason
+ )
+ let hostReady = hostReadyRaw || holdReady
// PiP sessions publish `reason=.starting` while the small window is
// coming up — treat that as warming so the mic stays orange (wait)
// instead of jumping into another cold start.
let hostWarming = FlowKeyboardHostWarming.isHostWarming(
hostReady: hostReady,
hostBusy: hostBusy,
- sessionActive: FlowSessionBridge.isSessionActive(),
+ sessionActive: sessionActive,
hostReachable: FlowSessionBridge.isHostReachable(),
isPendingFlowStart: isPendingFlowStart,
withinReadyGrace: withinReadyGrace,
snapshotReason: readySnapshot?.reason
)
- state.flowSessionActive = FlowSessionBridge.isSessionActive()
+ state.flowSessionActive = sessionActive
state.debugPendingFlowStart = isPendingFlowStart
state.debugFlowRecording = isFlowRecording
state.debugAwaitingFlowResult = isAwaitingFlowResult
@@ -626,6 +648,7 @@ final class KeyboardFlowCoordinator {
"utterance=\(result.utteranceId.uuidString.prefix(8)) "
+ "commandSeq=\(result.commandSeq) warning=\(result.warning == nil ? 0 : 1)"
)
+ recomputeMicVoiceAvailability()
return
}
if let result = matchingResult(), isTerminalFailure(result) {
diff --git a/OSGKeyboardExt/Typing/TypingRootView.swift b/OSGKeyboardExt/Typing/TypingRootView.swift
index fd52d33..4b03c9e 100644
--- a/OSGKeyboardExt/Typing/TypingRootView.swift
+++ b/OSGKeyboardExt/Typing/TypingRootView.swift
@@ -95,6 +95,8 @@ struct TypingRootView: View {
if !hasContent { candidatePanelMounted = false }
}
.onAppear {
+ // Primary warm-up lives in KVC.viewWillAppear (covers app switch).
+ // Keep this for first mount / surface flip into typing.
KeyboardHapticFeedback.prepare()
}
}
@@ -489,8 +491,12 @@ struct TypingRootView: View {
if !output.text.isEmpty {
onInsert(output.text)
}
- // Proxy context is up to date after inserts/deletes — refresh Shift.
- typing.syncAutocapitalization()
+ // Notes / some hosts lag `documentContextBeforeInput` — pass the edit we
+ // just applied so sentence Shift can arm after Return / "." immediately.
+ typing.syncAutocapitalization(
+ accountingForInsert: output.text,
+ deleteCount: output.deleteCount
+ )
}
private func keyWeight(label: String, index: Int, rowIndex: Int) -> CGFloat {
diff --git a/OSGKeyboardExt/Views/KeyboardRootView.swift b/OSGKeyboardExt/Views/KeyboardRootView.swift
index 958e6a1..41d3e7e 100644
--- a/OSGKeyboardExt/Views/KeyboardRootView.swift
+++ b/OSGKeyboardExt/Views/KeyboardRootView.swift
@@ -234,14 +234,22 @@ public struct KeyboardRootView: View {
}
private func bottomDeleteButton(disabled: Bool) -> some View {
- RepeatingDeleteButton(disabled: disabled) {
+ RepeatingDeleteButton(
+ disabled: disabled,
+ hapticIntensity: state.keyboardHapticIntensity
+ ) {
state.deleteBackward()
}
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
}
private func bottomSpaceButton(disabled: Bool) -> some View {
- RectangularToolbarButton(spaceStyle: true, label: "space", disabled: disabled) {
+ RectangularToolbarButton(
+ spaceStyle: true,
+ label: "space",
+ disabled: disabled,
+ hapticIntensity: state.keyboardHapticIntensity
+ ) {
state.insertSpace()
}
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
@@ -253,7 +261,8 @@ public struct KeyboardRootView: View {
title: title,
label: title,
disabled: disabled,
- isSend: state.returnKeyRole == .send
+ isSend: state.returnKeyRole == .send,
+ hapticIntensity: state.keyboardHapticIntensity
) {
state.insertNewline()
}
diff --git a/OSGKeyboardExt/Views/KeyboardTopControls.swift b/OSGKeyboardExt/Views/KeyboardTopControls.swift
index 74db5e9..3a4e715 100644
--- a/OSGKeyboardExt/Views/KeyboardTopControls.swift
+++ b/OSGKeyboardExt/Views/KeyboardTopControls.swift
@@ -154,7 +154,10 @@ struct KeyboardTopControls: View {
return
}
onInsert(output.text)
- typing.syncAutocapitalization()
+ typing.syncAutocapitalization(
+ accountingForInsert: output.text,
+ deleteCount: output.deleteCount
+ )
}
private func accessibilityLabel(for tab: KeyboardInputTab) -> String {
diff --git a/OSGKeyboardExt/Views/ToolbarActionButtons.swift b/OSGKeyboardExt/Views/ToolbarActionButtons.swift
index 9570b1b..b701a13 100644
--- a/OSGKeyboardExt/Views/ToolbarActionButtons.swift
+++ b/OSGKeyboardExt/Views/ToolbarActionButtons.swift
@@ -94,7 +94,7 @@ struct RepeatingPressButton: View {
var disabled: Bool = false
/// Plays the system delete click on each fire (matches stock keyboard).
var playsDeleteSound: Bool = true
- /// Typing-grid haptic strength; `.off` skips haptics (voice toolbar default).
+ /// Settings → General → Haptics; `.off` skips impact feedback.
var hapticIntensity: KeyboardHapticIntensity = .off
let action: () -> Void
@ViewBuilder let label: (_ isPressed: Bool) -> Label
@@ -166,10 +166,16 @@ struct RepeatingDeleteButton: View {
@Environment(\.colorScheme) private var colorScheme
let disabled: Bool
+ /// Mirrors Settings → General → Haptics (same as typing delete).
+ var hapticIntensity: KeyboardHapticIntensity = .off
let action: () -> Void
var body: some View {
- RepeatingPressButton(disabled: disabled, action: action) { isPressed in
+ RepeatingPressButton(
+ disabled: disabled,
+ hapticIntensity: hapticIntensity,
+ action: action
+ ) { isPressed in
ToolbarKeySurface(
isPressed: isPressed,
cornerRadius: ToolbarButtonMetrics.cornerRadius,
@@ -227,15 +233,24 @@ struct RectangularToolbarButton: View {
let label: String
let disabled: Bool
let isSend: Bool
+ /// Settings → General → Haptics; space / return use `.action` role.
+ var hapticIntensity: KeyboardHapticIntensity = .off
let action: () -> Void
- init(systemName: String, label: String, disabled: Bool = false, action: @escaping () -> Void) {
+ init(
+ systemName: String,
+ label: String,
+ disabled: Bool = false,
+ hapticIntensity: KeyboardHapticIntensity = .off,
+ action: @escaping () -> Void
+ ) {
self.systemName = systemName
self.spaceStyle = false
self.title = nil
self.label = label
self.disabled = disabled
self.isSend = false
+ self.hapticIntensity = hapticIntensity
self.action = action
}
@@ -244,6 +259,7 @@ struct RectangularToolbarButton: View {
label: String,
disabled: Bool = false,
isSend: Bool = false,
+ hapticIntensity: KeyboardHapticIntensity = .off,
action: @escaping () -> Void
) {
self.systemName = nil
@@ -251,17 +267,25 @@ struct RectangularToolbarButton: View {
self.label = label
self.disabled = disabled
self.isSend = isSend
+ self.hapticIntensity = hapticIntensity
self.action = action
self.title = title
}
- init(spaceStyle: Bool, label: String, disabled: Bool = false, action: @escaping () -> Void) {
+ init(
+ spaceStyle: Bool,
+ label: String,
+ disabled: Bool = false,
+ hapticIntensity: KeyboardHapticIntensity = .off,
+ action: @escaping () -> Void
+ ) {
self.systemName = nil
self.spaceStyle = spaceStyle
self.title = nil
self.label = label
self.disabled = disabled
self.isSend = false
+ self.hapticIntensity = hapticIntensity
self.action = action
}
@@ -299,13 +323,14 @@ struct RectangularToolbarButton: View {
isSend ? .white : NativeKeyboardKeyColors.text(for: colorScheme)
}
- // 按下即响、按下即执行,与系统键盘保持一致(Button 默认松手才触发)。
+ // 按下即响、即震、即执行,与系统键盘 / 打字面保持一致(Button 默认松手才触发)。
private var pressGesture: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { _ in
guard !disabled, !isPressing else { return }
isPressing = true
KeyboardSoundFeedback.keyClick()
+ KeyboardHapticFeedback.play(role: .action, intensity: hapticIntensity)
action()
}
.onEnded { _ in
diff --git a/OSGKeyboardExtTests/EnglishTypingTests.swift b/OSGKeyboardExtTests/EnglishTypingTests.swift
index a33f1a5..68a8e91 100644
--- a/OSGKeyboardExtTests/EnglishTypingTests.swift
+++ b/OSGKeyboardExtTests/EnglishTypingTests.swift
@@ -61,6 +61,15 @@ final class EnglishTypingTests: XCTestCase {
XCTAssertTrue(
TypingAutocapitalization.shouldCapitalize(precedingText: "Hello!\n", mode: .sentences)
)
+ // System keyboard (.sentences): Return starts a new line → capitalize.
+ // Notes-style document context after Return is typically "…\n".
+ XCTAssertTrue(
+ TypingAutocapitalization.shouldCapitalize(precedingText: "Hello\n", mode: .sentences),
+ "Return/newline must arm Shift (Notes multi-line)"
+ )
+ XCTAssertTrue(
+ TypingAutocapitalization.shouldCapitalize(precedingText: "Hello\n\n", mode: .sentences)
+ )
XCTAssertFalse(
TypingAutocapitalization.shouldCapitalize(precedingText: "Hello ", mode: .sentences)
)
@@ -72,6 +81,55 @@ final class EnglishTypingTests: XCTestCase {
)
}
+ @MainActor
+ func testEnglishShiftArmsAfterReturnLikeNotes() {
+ // Simulates Notes: proxy preceding text gains a trailing newline after Return.
+ var preceding = "Hello"
+ let typing = TypingSessionController()
+ typing.precedingTextProvider = { preceding }
+ typing.autocapitalizationModeProvider = { .sentences }
+ _ = typing.setLanguage(.english)
+ XCTAssertFalse(typing.shiftActive, "mid-word should not arm Shift")
+
+ _ = typing.handleReturn()
+ preceding = "Hello\n" // host document after inserting newline
+ typing.syncAutocapitalization(accountingForInsert: "\n")
+ XCTAssertTrue(
+ typing.shiftActive,
+ "after Return, Shift must light for next line (system sentences behavior)"
+ )
+ XCTAssertEqual(typing.keyRows.first?.first, "Q")
+ }
+
+ @MainActor
+ func testEnglishShiftArmsWhenProxyLagsAfterReturn() {
+ // Notes often still reports pre-Return context right after insertText("\n").
+ var preceding = "Hello"
+ let typing = TypingSessionController()
+ typing.precedingTextProvider = { preceding }
+ typing.autocapitalizationModeProvider = { .sentences }
+ _ = typing.setLanguage(.english)
+
+ _ = typing.handleReturn()
+ // Proxy intentionally stale — still "Hello" without "\n".
+ typing.syncAutocapitalization(accountingForInsert: "\n")
+ XCTAssertTrue(typing.shiftActive)
+ XCTAssertEqual(typing.keyRows.first?.first, "Q")
+ }
+
+ @MainActor
+ func testEnglishShiftArmsWhenProxyLagsAfterPeriod() {
+ var preceding = "Hello"
+ let typing = TypingSessionController()
+ typing.precedingTextProvider = { preceding }
+ typing.autocapitalizationModeProvider = { .sentences }
+ _ = typing.setLanguage(.english)
+
+ _ = typing.handleKey(".")
+ typing.syncAutocapitalization(accountingForInsert: ".")
+ XCTAssertTrue(typing.shiftActive)
+ }
+
@MainActor
func testEnglishIdleShowsNoCandidatesUntilLetterTyped() {
let typing = TypingSessionController()
diff --git a/OSGKeyboardExtTests/KeyboardSurfaceStateTests.swift b/OSGKeyboardExtTests/KeyboardSurfaceStateTests.swift
index 4d51f52..52adc9b 100644
--- a/OSGKeyboardExtTests/KeyboardSurfaceStateTests.swift
+++ b/OSGKeyboardExtTests/KeyboardSurfaceStateTests.swift
@@ -154,4 +154,120 @@ final class KeyboardSurfaceStateTests: XCTestCase {
XCTAssertFalse(typing.shiftHeld)
XCTAssertFalse(typing.capsLock)
}
+
+ func testChineseShiftInsertsUppercaseLatinBypassingRime() {
+ let engine = TrackingStubRimeEngine()
+ let typing = TypingSessionController(engine: { engine })
+
+ _ = typing.handleKey("⇧")
+ XCTAssertTrue(typing.shiftActive)
+
+ let output = typing.handleKey("N")
+ XCTAssertEqual(output, .insert("N"))
+ XCTAssertEqual(engine.processCharacterCallCount, 0)
+ XCTAssertFalse(typing.shiftActive, "one-shot Shift clears after Latin insert")
+ XCTAssertTrue(typing.composition.preedit.isEmpty)
+ }
+
+ func testChineseShiftPreservesExistingComposition() {
+ let engine = TrackingStubRimeEngine()
+ let typing = TypingSessionController(engine: { engine })
+ // Seed session composition via a lowercase letter (goes to Rime).
+ _ = typing.handleKey("n")
+ XCTAssertEqual(typing.composition.preedit, "n")
+
+ _ = typing.handleKey("⇧")
+ let output = typing.handleKey("A")
+ XCTAssertEqual(output, .insert("A"))
+ XCTAssertEqual(engine.processCharacterCallCount, 1, "only the unshifted letter hits Rime")
+ XCTAssertEqual(typing.composition.preedit, "n", "Shift Latin must not clear preedit")
+ }
+
+ func testChineseCapsLockKeepsInsertingUppercaseLatin() {
+ let engine = TrackingStubRimeEngine()
+ let typing = TypingSessionController(engine: { engine })
+
+ _ = typing.handleKey("⇧")
+ _ = typing.handleKey("⇧") // second tap → Caps Lock
+ XCTAssertTrue(typing.capsLock)
+
+ XCTAssertEqual(typing.handleKey("O"), .insert("O"))
+ XCTAssertEqual(typing.handleKey("S"), .insert("S"))
+ XCTAssertEqual(engine.processCharacterCallCount, 0)
+ XCTAssertTrue(typing.capsLock)
+ }
+
+ func testChineseUnshiftedLetterStillComposes() {
+ let engine = TrackingStubRimeEngine()
+ let typing = TypingSessionController(engine: { engine })
+
+ let output = typing.handleKey("n")
+ XCTAssertEqual(output, .none)
+ XCTAssertEqual(engine.processCharacterCallCount, 1)
+ XCTAssertEqual(engine.lastProcessedCharacter, "n")
+ XCTAssertEqual(typing.composition.preedit, "n")
+ }
+}
+
+/// Stub that records `processCharacter` calls for Chinese Shift bypass tests.
+@MainActor
+private final class TrackingStubRimeEngine: RimeEngineBridging {
+ var composition: TypingComposition = .empty
+ var isReady: Bool = true
+ var schema: TypingInputSchema = .fullPinyin
+ private(set) var processCharacterCallCount = 0
+ private(set) var lastProcessedCharacter: Character?
+
+ func prepare() async throws {}
+ func teardown() { composition = .empty }
+
+ func setLanguage(_ language: TypingInputLanguage) {
+ if language == .english { composition = .empty }
+ }
+
+ @discardableResult
+ func setSchema(_ schema: TypingInputSchema) -> Bool {
+ self.schema = schema
+ return true
+ }
+
+ func processCharacter(_ character: Character) -> String? {
+ processCharacterCallCount += 1
+ lastProcessedCharacter = character
+ composition = TypingComposition(
+ preedit: String(character).lowercased(),
+ candidates: [TypingCandidate(text: "你")]
+ )
+ return nil
+ }
+
+ func processBackspace() -> String? {
+ composition = .empty
+ return nil
+ }
+
+ func processSpace() -> String? {
+ composition = .empty
+ return " "
+ }
+
+ func processReturn() -> String? {
+ composition = .empty
+ return "\n"
+ }
+
+ func selectCandidate(at index: Int) -> String {
+ composition = .empty
+ return ""
+ }
+
+ func flushPreedit() -> String {
+ let raw = composition.preedit
+ composition = .empty
+ return raw
+ }
+
+ func clearComposition() {
+ composition = .empty
+ }
}
diff --git a/OSGKeyboardHostSupport/Services/CloudASR/VolcengineCloudASRClient.swift b/OSGKeyboardHostSupport/Services/CloudASR/VolcengineCloudASRClient.swift
index e87e7c0..4c13243 100644
--- a/OSGKeyboardHostSupport/Services/CloudASR/VolcengineCloudASRClient.swift
+++ b/OSGKeyboardHostSupport/Services/CloudASR/VolcengineCloudASRClient.swift
@@ -29,19 +29,15 @@ struct VolcengineCloudASRClient: CloudASRTranscribing, CloudASRStreamingCapable
onPartial: @escaping @Sendable (String) -> Void
) async throws -> any CloudASRStreamingSession {
_ = locale
- let credentials = try VolcengineCredentials.parse(
- apiKey: apiKey,
- fallbackResourceID: resolvedResourceID
- )
+ _ = resourceID // Product is locked to Doubao streaming 2.0 duration.
+ let credentials = VolcengineASRFields.parse(apiKey: apiKey)
+ guard credentials.hasUsableCredentials else { throw CloudASRError.noAPIKey }
let url = try resolvedEndpointURL()
let connectID = UUID().uuidString
var request = URLRequest(url: url)
request.timeoutInterval = 8
- request.setValue(credentials.appID, forHTTPHeaderField: "X-Api-App-Key")
- request.setValue(credentials.accessToken, forHTTPHeaderField: "X-Api-Access-Key")
- request.setValue(credentials.resourceID, forHTTPHeaderField: "X-Api-Resource-Id")
- request.setValue(connectID, forHTTPHeaderField: "X-Api-Connect-Id")
+ credentials.applyWebSocketAuthHeaders(to: &request, connectID: connectID)
let task = session.webSocketTask(with: request)
task.resume()
@@ -90,12 +86,6 @@ struct VolcengineCloudASRClient: CloudASRTranscribing, CloudASRStreamingCapable
live.cancel()
}
- private var resolvedResourceID: String {
- resourceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
- ? CloudASRModelCatalog.volcengineDefaultResourceID
- : resourceID.trimmingCharacters(in: .whitespacesAndNewlines)
- }
-
private func resolvedEndpointURL() throws -> URL {
let raw = endpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? CloudASRModelCatalog.volcengineEndpoint
@@ -424,46 +414,6 @@ private final class VolcengineStreamingSession: CloudASRStreamingSession, @unche
}
}
-private struct VolcengineCredentials {
- let appID: String
- let accessToken: String
- let resourceID: String
-
- static func parse(apiKey: String, fallbackResourceID: String) throws -> VolcengineCredentials {
- let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else { throw CloudASRError.noAPIKey }
-
- if let data = trimmed.data(using: .utf8),
- let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
- let appID = string(json, keys: ["app_id", "appId", "appid"])
- let token = string(json, keys: ["access_token", "accessToken", "token"])
- let resourceID = string(json, keys: ["resource_id", "resourceId", "resource"])
- ?? fallbackResourceID
- guard let appID, let token, !resourceID.isEmpty else { throw CloudASRError.noAPIKey }
- return VolcengineCredentials(appID: appID, accessToken: token, resourceID: resourceID)
- }
-
- let separators = CharacterSet(charactersIn: ":\n,")
- let parts = trimmed
- .components(separatedBy: separators)
- .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
- .filter { !$0.isEmpty }
- guard parts.count >= 2 else { throw CloudASRError.noAPIKey }
- let resourceID = parts.count >= 3 ? parts[2] : fallbackResourceID
- return VolcengineCredentials(appID: parts[0], accessToken: parts[1], resourceID: resourceID)
- }
-
- private static func string(_ json: [String: Any], keys: [String]) -> String? {
- for key in keys {
- if let value = json[key] as? String {
- let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
- if !trimmed.isEmpty { return trimmed }
- }
- }
- return nil
- }
-}
-
enum VolcengineMessageType: UInt8 {
case fullClientRequest = 0b0001
case audioOnlyRequest = 0b0010
diff --git a/OSGKeyboardHostSupport/Services/FlowAudioSessionCoordinator.swift b/OSGKeyboardHostSupport/Services/FlowAudioSessionCoordinator.swift
index 7f15fa9..3982633 100644
--- a/OSGKeyboardHostSupport/Services/FlowAudioSessionCoordinator.swift
+++ b/OSGKeyboardHostSupport/Services/FlowAudioSessionCoordinator.swift
@@ -68,10 +68,13 @@ public final class FlowAudioSessionCoordinator: @unchecked Sendable {
public func activateCapture() async throws -> FlowAudioSessionSnapshot {
let activation: CaptureActivation = try await perform {
if self.mode != .capture {
+ // `.voiceChat` turns on system speech DSP (noise suppression /
+ // AGC). `.measurement` feeds near-raw PCM and is weak against
+ // a competing talker in the same room.
try self.session.setCategory(
.playAndRecord,
- mode: .measurement,
- options: [.defaultToSpeaker, .allowBluetoothHFP, .mixWithOthers]
+ mode: FlowCaptureVoiceProcessing.captureMode,
+ options: FlowCaptureVoiceProcessing.captureOptions
)
}
if !self.active {
@@ -95,6 +98,9 @@ public final class FlowAudioSessionCoordinator: @unchecked Sendable {
category: "flow"
)
}
+ } else {
+ // Built-in near-talk preference (cardioid when available).
+ FlowCaptureVoiceProcessing.preferNearTalkBuiltInMic(on: self.session)
}
self.mode = .capture
return CaptureActivation(
diff --git a/OSGKeyboardHostSupport/Services/FlowCaptureVoiceProcessing.swift b/OSGKeyboardHostSupport/Services/FlowCaptureVoiceProcessing.swift
new file mode 100644
index 0000000..599aee8
--- /dev/null
+++ b/OSGKeyboardHostSupport/Services/FlowCaptureVoiceProcessing.swift
@@ -0,0 +1,108 @@
+// FlowCaptureVoiceProcessing.swift
+// OSGKeyboard · Host Support
+//
+// Capture-side speech front-end for local ASR: Apple Voice Processing
+// (noise suppression / AGC / AEC) plus near-talk built-in mic preference.
+// `.measurement` delivers near-raw PCM and is a poor fit for competing
+// talkers in the same room; `.voiceChat` + VP is the system path that
+// also unlocks Control Center Mic Modes (incl. Voice Isolation).
+
+import AVFoundation
+import Foundation
+import OSGKeyboardShared
+
+public enum FlowCaptureVoiceProcessing {
+
+ /// Speech-oriented session mode. Prefer over `.measurement` for ASR.
+ public static let captureMode: AVAudioSession.Mode = .voiceChat
+
+ public static let captureOptions: AVAudioSession.CategoryOptions = [
+ .defaultToSpeaker, .allowBluetoothHFP, .mixWithOthers
+ ]
+
+ /// Enable Apple Voice Processing on a stopped engine. Format may change
+ /// afterward — callers must re-read `inputNode` formats before `installTap`.
+ @discardableResult
+ public static func enableVoiceProcessing(on engine: AVAudioEngine) -> Bool {
+ let input = engine.inputNode
+ if input.isVoiceProcessingEnabled { return true }
+ do {
+ try input.setVoiceProcessingEnabled(true)
+ OSGDiag.log("voiceProcessing enabled", category: "flow")
+ logActiveMicrophoneMode()
+ return true
+ } catch {
+ OSGDiag.log(
+ "voiceProcessing enable failed: \(error.localizedDescription)",
+ category: "flow"
+ )
+ return false
+ }
+ }
+
+ /// Prefer a near-field built-in data source (front/lower + cardioid when
+ /// available). No-op while Bluetooth HFP is preferred or active — keep
+ /// the existing headset route.
+ public static func preferNearTalkBuiltInMic(on session: AVAudioSession) {
+ if session.currentRoute.inputs.first?.portType == .bluetoothHFP { return }
+ if session.preferredInput?.portType == .bluetoothHFP { return }
+
+ guard let builtIn = session.availableInputs?.first(where: {
+ $0.portType == .builtInMic
+ }) else { return }
+
+ let sources = builtIn.dataSources ?? []
+ let preferred =
+ sources.first(where: { $0.orientation == .front })
+ ?? sources.first(where: { $0.location == .lower })
+ ?? sources.first
+
+ if let preferred {
+ if preferred.supportedPolarPatterns?.contains(.cardioid) == true {
+ do {
+ try preferred.setPreferredPolarPattern(.cardioid)
+ } catch {
+ OSGDiag.log(
+ "cardioid polar pattern failed: \(error.localizedDescription)",
+ category: "flow"
+ )
+ }
+ }
+ do {
+ try builtIn.setPreferredDataSource(preferred)
+ } catch {
+ OSGDiag.log(
+ "preferred data source failed: \(error.localizedDescription)",
+ category: "flow"
+ )
+ }
+ }
+
+ do {
+ try session.setPreferredInput(builtIn)
+ } catch {
+ OSGDiag.log(
+ "preferred built-in mic failed: \(error.localizedDescription)",
+ category: "flow"
+ )
+ }
+ }
+
+ public static func logActiveMicrophoneMode() {
+ let preferred = micModeLabel(AVCaptureDevice.preferredMicrophoneMode)
+ let active = micModeLabel(AVCaptureDevice.activeMicrophoneMode)
+ OSGDiag.log(
+ "micMode preferred=\(preferred) active=\(active)",
+ category: "flow"
+ )
+ }
+
+ private static func micModeLabel(_ mode: AVCaptureDevice.MicrophoneMode) -> String {
+ switch mode {
+ case .standard: return "standard"
+ case .wideSpectrum: return "wideSpectrum"
+ case .voiceIsolation: return "voiceIsolation"
+ @unknown default: return "unknown(\(mode.rawValue))"
+ }
+ }
+}
diff --git a/OSGKeyboardHostSupport/Services/FlowContinuousCapture.swift b/OSGKeyboardHostSupport/Services/FlowContinuousCapture.swift
index e58d154..38540a6 100644
--- a/OSGKeyboardHostSupport/Services/FlowContinuousCapture.swift
+++ b/OSGKeyboardHostSupport/Services/FlowContinuousCapture.swift
@@ -604,13 +604,25 @@ public final class FlowContinuousCapture {
FlowAudioEngineHandle(audioEngine)
)
+ // Voice Processing must be enabled while the engine is stopped, and
+ // it can change the RemoteIO format — enable first, then sync rates.
var candidateEngine = AVAudioEngine()
+ var voiceProcessingOn = FlowCaptureVoiceProcessing.enableVoiceProcessing(
+ on: candidateEngine
+ )
var inputNode = candidateEngine.inputNode
var hardwareFormat = inputNode.inputFormat(forBus: 0)
var outputFormat = inputNode.outputFormat(forBus: 0)
- for _ in 0..<3 where abs(hardwareFormat.sampleRate - sessionSnapshot.sampleRate) >= 1 {
+ var resolvedSession = sessionSnapshot
+ for _ in 0..<3 where abs(hardwareFormat.sampleRate - resolvedSession.sampleRate) >= 1 {
try? await Task.sleep(nanoseconds: 50_000_000)
+ // Re-enter capture so the session rate tracks VP / route shifts.
+ resolvedSession = (try? await FlowAudioSessionCoordinator.shared.activateCapture())
+ ?? resolvedSession
candidateEngine = AVAudioEngine()
+ voiceProcessingOn = FlowCaptureVoiceProcessing.enableVoiceProcessing(
+ on: candidateEngine
+ )
inputNode = candidateEngine.inputNode
hardwareFormat = inputNode.inputFormat(forBus: 0)
outputFormat = inputNode.outputFormat(forBus: 0)
@@ -619,8 +631,9 @@ public final class FlowContinuousCapture {
"audioSession.active",
"hwRate=\(Int(hardwareFormat.sampleRate)) hwChannels=\(hardwareFormat.channelCount) "
+ "outputRate=\(Int(outputFormat.sampleRate)) "
- + "sessionRate=\(Int(sessionSnapshot.sampleRate)) "
- + "route=\(sessionSnapshot.inputPortType)"
+ + "sessionRate=\(Int(resolvedSession.sampleRate)) "
+ + "route=\(resolvedSession.inputPortType) "
+ + "voiceProcessing=\(voiceProcessingOn ? 1 : 0)"
)
guard hardwareFormat.sampleRate > 0, hardwareFormat.channelCount > 0 else {
FlowTrace.warn(
@@ -632,8 +645,8 @@ public final class FlowContinuousCapture {
channels: Int(hardwareFormat.channelCount)
)
}
- guard sessionSnapshot.sampleRate <= 0
- || abs(hardwareFormat.sampleRate - sessionSnapshot.sampleRate) < 1 else {
+ guard resolvedSession.sampleRate <= 0
+ || abs(hardwareFormat.sampleRate - resolvedSession.sampleRate) < 1 else {
throw StartError.invalidHardwareFormat(
sampleRate: hardwareFormat.sampleRate,
channels: Int(hardwareFormat.channelCount)
@@ -703,7 +716,7 @@ public final class FlowContinuousCapture {
throw StartError.engineStartFailed(error.localizedDescription)
}
audioEngine = candidateEngine
- activeRouteSnapshot = sessionSnapshot
+ activeRouteSnapshot = resolvedSession
engineActivationCount += 1
lastActivationAt = Date()
FlowTrace.capture("engine.started", "running=\(audioEngine.isRunning ? 1 : 0)")
diff --git a/OSGKeyboardHostSupport/Services/LiveDictationController.swift b/OSGKeyboardHostSupport/Services/LiveDictationController.swift
index f14bc83..5c6e144 100644
--- a/OSGKeyboardHostSupport/Services/LiveDictationController.swift
+++ b/OSGKeyboardHostSupport/Services/LiveDictationController.swift
@@ -177,30 +177,27 @@ public final class LiveDictationController: ObservableObject {
// 3. Audio session — only configure once per process.
//
- // Category is `.record` (not `.playAndRecord`) because the
- // preview never plays back audio — it just records from the
- // mic and hands the buffers to `SpeechAnalyzer`. On the
- // iOS Simulator, `.playAndRecord` requires the
- // `AURemoteIO` Audio Unit's *output* side to also be
- // enabled, but the simulator's "speaker" reports a 0 Hz
- // hardware format, so `AURemoteIO::enable` fails with
- // `kAudioUnitErr_FormatNotSupported` (-10851) and any
- // subsequent `installTap` traps with "Failed to create tap
- // due to format mismatch". `.record` skips the output
- // side entirely, so the simulator can record.
- //
- // The real keyboard extension (`OSGKeyboardExt`) keeps
- // `.playAndRecord` because it runs on a real device where
- // the output side has a real hardware format, and may want
- // to play click sounds / haptic feedback. Only the preview
- // needs the simulator-friendly category.
+ // On device we use `.playAndRecord` + `.voiceChat` so Apple Voice
+ // Processing can suppress competing talkers. The iOS Simulator's
+ // speaker reports a 0 Hz output format, so `.playAndRecord` fails
+ // with `kAudioUnitErr_FormatNotSupported` (-10851); keep `.record`
+ // there so preview still works under CoreSimulator.
if !didConfigureAudioSession {
do {
let session = AVAudioSession.sharedInstance()
- try session.setCategory(.record,
- mode: .measurement,
- options: [])
+ #if targetEnvironment(simulator)
+ try session.setCategory(.record, mode: .default, options: [])
+ #else
+ try session.setCategory(
+ .playAndRecord,
+ mode: FlowCaptureVoiceProcessing.captureMode,
+ options: FlowCaptureVoiceProcessing.captureOptions
+ )
+ #endif
try session.setActive(true, options: .notifyOthersOnDeactivation)
+ #if !targetEnvironment(simulator)
+ FlowCaptureVoiceProcessing.preferNearTalkBuiltInMic(on: session)
+ #endif
didConfigureAudioSession = true
} catch {
debug("audio session failed: \(error.localizedDescription)")
@@ -214,6 +211,8 @@ public final class LiveDictationController: ObservableObject {
// The route may have changed while the preview was closed. A fresh
// engine created after session activation avoids a stale input node.
audioEngine = AVAudioEngine()
+ // Enable VP before reading hardware format — RemoteIO rate can shift.
+ _ = FlowCaptureVoiceProcessing.enableVoiceProcessing(on: audioEngine)
// 4. Spin up the engine + ASR.
phase = .recording
diff --git a/OSGKeyboardMac/MacPolishStylesView.swift b/OSGKeyboardMac/MacPolishStylesView.swift
index 891d919..64bbba0 100644
--- a/OSGKeyboardMac/MacPolishStylesView.swift
+++ b/OSGKeyboardMac/MacPolishStylesView.swift
@@ -146,7 +146,8 @@ struct MacPolishStylesView: View {
duplicate: {
editingPack = PolishStylePack(
name: "\(pack.displayName(language: lang)) \(MacL10n.string("mac.styles.copy", language: lang))",
- prompt: pack.prompt
+ prompt: pack.prompt,
+ allowsAddedEmoji: pack.allowsAddedEmoji
)
showEditor = true
},
@@ -347,6 +348,7 @@ private struct MacPolishStyleEditor: View {
@Environment(\.themePalette) private var palette
@State private var name: String
@State private var prompt: String
+ @State private var allowsAddedEmoji: Bool
init(
pack: PolishStylePack?,
@@ -358,6 +360,7 @@ private struct MacPolishStyleEditor: View {
self.onSave = onSave
_name = State(initialValue: pack?.name ?? "")
_prompt = State(initialValue: pack?.prompt ?? PolishStylePackCatalog.newUserPromptTemplate)
+ _allowsAddedEmoji = State(initialValue: pack?.allowsAddedEmoji ?? false)
}
var body: some View {
@@ -366,6 +369,13 @@ private struct MacPolishStyleEditor: View {
.font(TypeStyle.title2)
TextField(MacL10n.string("mac.styles.name", language: language), text: $name)
.textFieldStyle(.roundedBorder)
+ Toggle(
+ MacL10n.string("mac.styles.allowsAddedEmoji", language: language),
+ isOn: $allowsAddedEmoji
+ )
+ Text(MacL10n.string("mac.styles.allowsAddedEmoji.hint", language: language))
+ .font(TypeStyle.caption2)
+ .foregroundStyle(palette.textTertiary)
HStack {
Text(MacL10n.string("mac.styles.prompt", language: language))
.font(MacSettingsType.sectionTitle)
@@ -380,13 +390,19 @@ private struct MacPolishStyleEditor: View {
}
TextEditor(text: $prompt)
.font(.body.monospaced())
- .frame(minHeight: 360)
+ .frame(minHeight: 320)
.padding(4)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.medium))
.overlay(
RoundedRectangle(cornerRadius: Radius.medium)
.stroke(palette.divider, lineWidth: 1)
)
+ .onChange(of: prompt) { _, newValue in
+ if !allowsAddedEmoji,
+ PolishStylePack.promptDeclaresAddedEmojiOptIn(newValue) {
+ allowsAddedEmoji = true
+ }
+ }
Text(MacL10n.string("mac.styles.hint", language: language))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
@@ -399,6 +415,8 @@ private struct MacPolishStyleEditor: View {
id: pack?.id ?? "user.\(UUID().uuidString.lowercased())",
name: name,
prompt: prompt,
+ allowsAddedEmoji: allowsAddedEmoji
+ || PolishStylePack.promptDeclaresAddedEmojiOptIn(prompt),
kind: .user,
createdAt: pack?.createdAt ?? Date()
)
@@ -414,7 +432,7 @@ private struct MacPolishStyleEditor: View {
}
}
.padding(Spacing.xl)
- .frame(width: 680, height: 590)
+ .frame(width: 680, height: 640)
.background(palette.background)
}
}
diff --git a/OSGKeyboardMac/MacSettingsView.swift b/OSGKeyboardMac/MacSettingsView.swift
index 701293d..3f6678a 100644
--- a/OSGKeyboardMac/MacSettingsView.swift
+++ b/OSGKeyboardMac/MacSettingsView.swift
@@ -188,34 +188,52 @@ struct MacSettingsView: View {
@ViewBuilder
private var volcengineAsrRows: some View {
- MacCredentialField(
- title: MacL10n.string("mac.settings.volcengineAppId", language: lang),
- placeholder: "APP ID",
- text: Binding(
- get: { macVolcengineFields.appID },
- set: { updateMacVolcengine(appID: $0) }
- ),
- isSecret: true
+ MacProviderThinkingRow(
+ title: MacL10n.string("mac.settings.volcengineApiKeyMode", language: lang),
+ subtitle: MacL10n.string("mac.settings.volcengineApiKeyModeSubtitle", language: lang),
+ isOn: Binding(
+ get: { macVolcengineFields.usesAPIKeyAuth },
+ set: { updateMacVolcengine(authMode: $0 ? .apiKey : .appToken) }
+ )
)
- MacCredentialField(
- title: MacL10n.string("mac.settings.volcengineAccessToken", language: lang),
- placeholder: "Access Token",
- text: Binding(
- get: { macVolcengineFields.accessToken },
- set: { updateMacVolcengine(accessToken: $0) }
- ),
- isSecret: true
+ if macVolcengineFields.usesAPIKeyAuth {
+ MacCredentialField(
+ title: MacL10n.string("mac.settings.volcengineApiKey", language: lang),
+ placeholder: "API Key",
+ text: Binding(
+ get: { macVolcengineFields.apiKeyCredential },
+ set: { updateMacVolcengine(apiKeyCredential: $0) }
+ ),
+ isSecret: true
+ )
+ } else {
+ MacCredentialField(
+ title: MacL10n.string("mac.settings.volcengineAppId", language: lang),
+ placeholder: "APP ID",
+ text: Binding(
+ get: { macVolcengineFields.appID },
+ set: { updateMacVolcengine(appID: $0) }
+ ),
+ isSecret: true
+ )
+ MacCredentialField(
+ title: MacL10n.string("mac.settings.volcengineAccessToken", language: lang),
+ placeholder: "Access Token",
+ text: Binding(
+ get: { macVolcengineFields.accessToken },
+ set: { updateMacVolcengine(accessToken: $0) }
+ ),
+ isSecret: true
+ )
+ }
+ MacProviderNoteRow(
+ text: MacL10n.string(
+ macVolcengineFields.usesAPIKeyAuth
+ ? "mac.settings.volcengineNoteApiKey"
+ : "mac.settings.volcengineNoteAppToken",
+ language: lang
+ )
)
- MacCredentialField(
- title: MacL10n.string("mac.settings.volcengineResourceId", language: lang),
- placeholder: CloudASRModelCatalog.defaultModel(for: "volcengine"),
- text: Binding(
- get: { macVolcengineFields.resourceID },
- set: { updateMacVolcengine(resourceID: $0) }
- ),
- defaultValue: CloudASRModelCatalog.defaultModel(for: "volcengine")
- )
- MacProviderNoteRow(text: MacL10n.string("mac.settings.volcengineNote", language: lang))
}
@ViewBuilder
@@ -407,22 +425,21 @@ struct MacSettingsView: View {
}
private var macVolcengineFields: VolcengineASRFields {
- VolcengineASRFields.parse(
- apiKey: viewModel.config.asrApiKey,
- resourceFallback: viewModel.config.asrModel.isEmpty
- ? CloudASRModelCatalog.defaultModel(for: "volcengine")
- : viewModel.config.asrModel
- )
+ VolcengineASRFields.parse(apiKey: viewModel.config.asrApiKey)
}
- private func updateMacVolcengine(appID: String? = nil, accessToken: String? = nil, resourceID: String? = nil) {
+ private func updateMacVolcengine(
+ authMode: VolcengineASRAuthMode? = nil,
+ appID: String? = nil,
+ accessToken: String? = nil,
+ apiKeyCredential: String? = nil
+ ) {
var fields = macVolcengineFields
+ if let authMode { fields.authMode = authMode }
if let appID { fields.appID = appID }
if let accessToken { fields.accessToken = accessToken }
- if let resourceID {
- fields.resourceID = resourceID
- viewModel.config.asrModel = resourceID
- }
+ if let apiKeyCredential { fields.apiKeyCredential = apiKeyCredential }
+ viewModel.config.asrModel = VolcengineASRFields.fixedResourceID
viewModel.config.asrApiKey = fields.encodedAPIKey
}
diff --git a/OSGKeyboardShared/Models/AppGroupConfiguration.swift b/OSGKeyboardShared/Models/AppGroupConfiguration.swift
index c8be66f..6353446 100644
--- a/OSGKeyboardShared/Models/AppGroupConfiguration.swift
+++ b/OSGKeyboardShared/Models/AppGroupConfiguration.swift
@@ -140,7 +140,13 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public var isCloudASRKeyMissing: Bool {
guard engineMode == "cloud" else { return false }
- return asrApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ let key = asrApiKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !key.isEmpty else { return true }
+ // Volcengine may store auth_mode JSON before credentials are filled.
+ if asrProviderId == "volcengine" {
+ return !VolcengineASRFields.parse(apiKey: key).hasUsableCredentials
+ }
+ return false
}
public var isPolishKeyMissing: Bool {
diff --git a/OSGKeyboardShared/Models/LLMProvider.swift b/OSGKeyboardShared/Models/LLMProvider.swift
index c015850..4a72bcc 100644
--- a/OSGKeyboardShared/Models/LLMProvider.swift
+++ b/OSGKeyboardShared/Models/LLMProvider.swift
@@ -191,7 +191,7 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
defaultBaseURL: "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async",
defaultModel: "volc.seedasr.sauc.duration",
apiKeyURL: URL(string: "https://console.volcengine.com/speech"),
- blurb: "流式大模型 ASR · API Key 填 appId:accessToken[:resourceId]",
+ blurb: "流式大模型 ASR · 旧版 AppID+Token / 新版 API Key",
isUserSelectable: false
),
.init(
diff --git a/OSGKeyboardShared/Models/PolishStylePack.swift b/OSGKeyboardShared/Models/PolishStylePack.swift
index 815d7b1..bdf1e2e 100644
--- a/OSGKeyboardShared/Models/PolishStylePack.swift
+++ b/OSGKeyboardShared/Models/PolishStylePack.swift
@@ -15,6 +15,9 @@ public struct PolishStylePack: Codable, Equatable, Identifiable, Sendable {
public let id: String
public var name: String
public var prompt: String
+ /// When true, polish may keep model-added emoji and the prompt overrides R5.
+ /// Defaults off so existing / builtin styles stay emoji-strict.
+ public var allowsAddedEmoji: Bool
public let kind: Kind
public let createdAt: Date
public var updatedAt: Date
@@ -23,6 +26,7 @@ public struct PolishStylePack: Codable, Equatable, Identifiable, Sendable {
id: String = "user.\(UUID().uuidString.lowercased())",
name: String,
prompt: String,
+ allowsAddedEmoji: Bool = false,
kind: Kind = .user,
createdAt: Date = Date(),
updatedAt: Date? = nil
@@ -30,6 +34,7 @@ public struct PolishStylePack: Codable, Equatable, Identifiable, Sendable {
self.id = id
self.name = name
self.prompt = prompt
+ self.allowsAddedEmoji = allowsAddedEmoji
self.kind = kind
self.createdAt = createdAt
self.updatedAt = updatedAt ?? createdAt
@@ -39,6 +44,46 @@ public struct PolishStylePack: Codable, Equatable, Identifiable, Sendable {
guard kind == .builtin else { return name }
return SharedL10n.string("polishStyle.\(id.dropFirst("builtin.".count))", language: language)
}
+
+ /// Effective emoji policy for polish: explicit toggle, or a custom prompt that
+ /// clearly opts in (so paste-only custom styles still keep model-added emoji).
+ public var effectiveAllowsAddedEmoji: Bool {
+ if allowsAddedEmoji { return true }
+ guard kind == .user else { return false }
+ return Self.promptDeclaresAddedEmojiOptIn(prompt)
+ }
+
+ /// Heuristic for custom prompts that declare “add mood emoji” themselves.
+ public static func promptDeclaresAddedEmojiOptIn(_ prompt: String) -> Bool {
+ let markers = [
+ "允许新增 emoji",
+ "允许新增emoji",
+ "按情绪点缀",
+ "按原文情绪",
+ "Emoji 覆盖",
+ "outranks global R5",
+ "may add emojis",
+ "allow mood emoji",
+ "allowsAddedEmoji",
+ ]
+ return markers.contains { prompt.localizedCaseInsensitiveContains($0) }
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case id, name, prompt, allowsAddedEmoji, kind, createdAt, updatedAt
+ }
+
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ id = try container.decode(String.self, forKey: .id)
+ name = try container.decode(String.self, forKey: .name)
+ prompt = try container.decode(String.self, forKey: .prompt)
+ // Older synced packs omit the key — stay emoji-strict.
+ allowsAddedEmoji = try container.decodeIfPresent(Bool.self, forKey: .allowsAddedEmoji) ?? false
+ kind = try container.decode(Kind.self, forKey: .kind)
+ createdAt = try container.decode(Date.self, forKey: .createdAt)
+ updatedAt = try container.decode(Date.self, forKey: .updatedAt)
+ }
}
public enum PolishStyleLimits {
@@ -92,6 +137,7 @@ public struct PolishStyleCatalog: Codable, Equatable, Sendable {
var updated = pack
updated.name = name
updated.prompt = prompt
+ updated.allowsAddedEmoji = pack.allowsAddedEmoji
updated.updatedAt = date
entries[index] = updated
} else {
@@ -101,6 +147,7 @@ public struct PolishStyleCatalog: Codable, Equatable, Sendable {
var created = pack
created.name = name
created.prompt = prompt
+ created.allowsAddedEmoji = pack.allowsAddedEmoji
created.updatedAt = date
entries.append(created)
}
diff --git a/OSGKeyboardShared/Models/ProviderConfig.swift b/OSGKeyboardShared/Models/ProviderConfig.swift
index 892e153..4616734 100644
--- a/OSGKeyboardShared/Models/ProviderConfig.swift
+++ b/OSGKeyboardShared/Models/ProviderConfig.swift
@@ -313,7 +313,14 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
public var isASRConfigured: Bool {
guard !isLocalEngine else { return true }
- return !asrApiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ let key = asrApiKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ let hasKey: Bool = {
+ if asrProviderId == "volcengine" {
+ return VolcengineASRFields.parse(apiKey: key).hasUsableCredentials
+ }
+ return !key.isEmpty
+ }()
+ return hasKey
&& (!asrBaseURL.isEmpty || CloudASRModelCatalog.strategy(for: asrProviderId) != .prompt)
}
diff --git a/OSGKeyboardShared/Models/VolcengineASRFields.swift b/OSGKeyboardShared/Models/VolcengineASRFields.swift
index 97b786a..cd50048 100644
--- a/OSGKeyboardShared/Models/VolcengineASRFields.swift
+++ b/OSGKeyboardShared/Models/VolcengineASRFields.swift
@@ -2,65 +2,155 @@
// OSGKeyboard · Shared
//
// Parse / encode Volcengine SAUC credentials stored in the ASR API key field.
+// Supports legacy AppID+AccessToken (old console) and single API Key (new console).
import Foundation
+/// Volcengine speech console auth style for SAUC streaming ASR.
+public enum VolcengineASRAuthMode: String, Sendable, Equatable {
+ /// Old console: `X-Api-App-Key` + `X-Api-Access-Key`.
+ case appToken = "app_token"
+ /// New console: single `X-Api-Key`.
+ case apiKey = "api_key"
+}
+
public struct VolcengineASRFields: Sendable, Equatable {
+ public var authMode: VolcengineASRAuthMode
public var appID: String
public var accessToken: String
- public var resourceID: String
+ /// New-console API Key (`X-Api-Key`). Kept alongside app-token fields so the
+ /// settings toggle can switch modes without wiping the other credential set.
+ public var apiKeyCredential: String
+
+ /// Locked product: Doubao streaming ASR 2.0 · duration billing.
+ public static let fixedResourceID = CloudASRModelCatalog.volcengineDefaultResourceID
public init(
+ authMode: VolcengineASRAuthMode = .appToken,
appID: String = "",
accessToken: String = "",
- resourceID: String = CloudASRModelCatalog.defaultModel(for: "volcengine")
+ apiKeyCredential: String = ""
) {
+ self.authMode = authMode
self.appID = appID
self.accessToken = accessToken
- self.resourceID = resourceID
+ self.apiKeyCredential = apiKeyCredential
+ }
+
+ /// Always `volc.seedasr.sauc.duration` — not user-editable.
+ public var resourceID: String { Self.fixedResourceID }
+
+ public var usesAPIKeyAuth: Bool { authMode == .apiKey }
+
+ public var hasUsableCredentials: Bool {
+ switch authMode {
+ case .appToken:
+ return !appID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ && !accessToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ case .apiKey:
+ return !apiKeyCredential.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
}
public var encodedAPIKey: String {
- let object = [
- "app_id": appID,
- "access_token": accessToken,
- "resource_id": resourceID,
+ var object: [String: String] = [
+ "auth_mode": authMode.rawValue,
+ "resource_id": Self.fixedResourceID,
]
+ // Persist both credential sets so toggling auth mode is non-destructive.
+ if !appID.isEmpty { object["app_id"] = appID }
+ if !accessToken.isEmpty { object["access_token"] = accessToken }
+ if !apiKeyCredential.isEmpty { object["api_key"] = apiKeyCredential }
+
guard let data = try? JSONSerialization.data(withJSONObject: object),
let string = String(data: data, encoding: .utf8) else {
- return [appID, accessToken, resourceID].joined(separator: ":")
+ switch authMode {
+ case .appToken:
+ return [appID, accessToken, Self.fixedResourceID].joined(separator: ":")
+ case .apiKey:
+ return apiKeyCredential
+ }
}
return string
}
- public static func parse(apiKey: String, resourceFallback: String) -> VolcengineASRFields {
+ /// Apply SAUC WebSocket handshake headers for the active auth mode.
+ public func applyWebSocketAuthHeaders(to request: inout URLRequest, connectID: String) {
+ request.setValue(Self.fixedResourceID, forHTTPHeaderField: "X-Api-Resource-Id")
+ request.setValue(connectID, forHTTPHeaderField: "X-Api-Connect-Id")
+ switch authMode {
+ case .apiKey:
+ request.setValue(
+ apiKeyCredential.trimmingCharacters(in: .whitespacesAndNewlines),
+ forHTTPHeaderField: "X-Api-Key"
+ )
+ case .appToken:
+ request.setValue(
+ appID.trimmingCharacters(in: .whitespacesAndNewlines),
+ forHTTPHeaderField: "X-Api-App-Key"
+ )
+ request.setValue(
+ accessToken.trimmingCharacters(in: .whitespacesAndNewlines),
+ forHTTPHeaderField: "X-Api-Access-Key"
+ )
+ }
+ }
+
+ /// - Parameter resourceFallback: Ignored; resource is always `fixedResourceID`.
+ /// Kept so call sites stay source-compatible.
+ public static func parse(apiKey: String, resourceFallback: String = "") -> VolcengineASRFields {
+ _ = resourceFallback
let trimmed = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
- var fields = VolcengineASRFields(
- appID: "",
- accessToken: "",
- resourceID: resourceFallback.isEmpty
- ? CloudASRModelCatalog.defaultModel(for: "volcengine")
- : resourceFallback
- )
+ var fields = VolcengineASRFields()
+
+ guard !trimmed.isEmpty else { return fields }
if let data = trimmed.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
fields.appID = string(json, keys: ["app_id", "appId", "appid"]) ?? ""
fields.accessToken = string(json, keys: ["access_token", "accessToken", "token"]) ?? ""
- fields.resourceID = string(json, keys: ["resource_id", "resourceId", "resource"]) ?? fields.resourceID
+ fields.apiKeyCredential = string(json, keys: ["api_key", "apiKey"]) ?? ""
+ fields.authMode = resolveAuthMode(
+ raw: string(json, keys: ["auth_mode", "authMode"]),
+ hasAPIKey: !fields.apiKeyCredential.isEmpty,
+ hasAppToken: !fields.appID.isEmpty && !fields.accessToken.isEmpty
+ )
return fields
}
+ // Legacy colon form is always old-console app-token auth.
let parts = trimmed
.components(separatedBy: CharacterSet(charactersIn: ":\n,"))
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
if parts.indices.contains(0) { fields.appID = parts[0] }
if parts.indices.contains(1) { fields.accessToken = parts[1] }
- if parts.indices.contains(2) { fields.resourceID = parts[2] }
+ fields.authMode = .appToken
return fields
}
+ private static func resolveAuthMode(
+ raw: String?,
+ hasAPIKey: Bool,
+ hasAppToken: Bool
+ ) -> VolcengineASRAuthMode {
+ if let raw {
+ let normalized = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ if normalized == VolcengineASRAuthMode.apiKey.rawValue || normalized == "apikey" {
+ return .apiKey
+ }
+ if normalized == VolcengineASRAuthMode.appToken.rawValue
+ || normalized == "apptoken"
+ || normalized == "app_id_token" {
+ return .appToken
+ }
+ }
+ // Legacy JSON without auth_mode: prefer app-token when present.
+ if hasAppToken { return .appToken }
+ if hasAPIKey { return .apiKey }
+ return .appToken
+ }
+
private static func string(_ json: [String: Any], keys: [String]) -> String? {
for key in keys {
if let value = json[key] as? String {
diff --git a/OSGKeyboardShared/Services/FlowKeyboardPolicies.swift b/OSGKeyboardShared/Services/FlowKeyboardPolicies.swift
index 5cb9a51..d7f09a9 100644
--- a/OSGKeyboardShared/Services/FlowKeyboardPolicies.swift
+++ b/OSGKeyboardShared/Services/FlowKeyboardPolicies.swift
@@ -14,8 +14,39 @@ public enum FlowKeyboardHostWarming {
reason == .recording || reason == .processing || reason == .awaitingDelivery
}
+ /// Keep the mic green after the session has already proven ready.
+ ///
+ /// Inter-utterance PiP flaps (mic release, ack lag, brief `reason=.starting`)
+ /// used to flash yellow「正在启动画中画」even though Picture in Picture was
+ /// already running. Hold ready through those windows; real cold starts still
+ /// go through `isHostWarming` while `sessionProvenReady` is false.
+ public static func shouldHoldReady(
+ hostReady: Bool,
+ hostBusy: Bool,
+ sessionActive: Bool,
+ sessionProvenReady: Bool,
+ isPendingFlowStart: Bool,
+ snapshotReason: FlowReadySnapshot.Reason?
+ ) -> Bool {
+ guard !hostReady,
+ sessionProvenReady,
+ sessionActive,
+ !isPendingFlowStart else {
+ return false
+ }
+ // After insert the host may still publish awaitingDelivery until it
+ // consumes the ack — that is not a PiP restart.
+ if hostBusy {
+ return snapshotReason == .awaitingDelivery
+ }
+ return true
+ }
+
/// Session lives but ready contract is not fresh — keep mic orange (wait)
/// instead of launching another cold start.
+ ///
+ /// `withinReadyGrace` is intentionally unused for warming: a recent ready
+ /// must hold green via `shouldHoldReady`, not flash preparingSession.
public static func isHostWarming(
hostReady: Bool,
hostBusy: Bool,
@@ -25,13 +56,13 @@ public enum FlowKeyboardHostWarming {
withinReadyGrace: Bool,
snapshotReason: FlowReadySnapshot.Reason?
) -> Bool {
- !hostReady
+ _ = withinReadyGrace
+ return !hostReady
&& !hostBusy
&& sessionActive
&& (
hostReachable
|| isPendingFlowStart
- || withinReadyGrace
|| snapshotReason == .starting
)
}
diff --git a/OSGKeyboardShared/Services/FlowSessionBridge.swift b/OSGKeyboardShared/Services/FlowSessionBridge.swift
index 0230440..5b11c07 100644
--- a/OSGKeyboardShared/Services/FlowSessionBridge.swift
+++ b/OSGKeyboardShared/Services/FlowSessionBridge.swift
@@ -671,6 +671,8 @@ public enum FlowSessionBridge {
store.removeObject(forKey: FlowSessionKeys.audioLevels)
store.removeObject(forKey: FlowSessionKeys.lastActivityAt)
clearHostReady(defaults: store, notify: false)
+ // Previous generation may have died mid Rime/CLM/ASR with hostHeavy=1.
+ clearHostHeavy(defaults: store)
flush(store)
}
@@ -701,13 +703,47 @@ public enum FlowSessionBridge {
/// avoid stacking typing-engine RSS on top.
public static func setHostHeavy(_ heavy: Bool, defaults: UserDefaults? = nil) {
let store = resolvedDefaults(defaults)
- store.set(heavy, forKey: FlowSessionKeys.hostHeavy)
+ if heavy {
+ store.set(true, forKey: FlowSessionKeys.hostHeavy)
+ store.set(Date().timeIntervalSince1970, forKey: FlowSessionKeys.hostHeavyAt)
+ } else {
+ clearHostHeavy(defaults: store)
+ }
flush(store)
OSGDiag.log("hostHeavy=\(heavy ? 1 : 0) \(OSGDiag.memoryTag())", category: "flow")
}
+ /// True only while the host recently marked itself busy. A sticky `true`
+ /// left by a dead host (no `setHostHeavy(false)`) expires after
+ /// `hostHeavyMaxAge` so typing 中文/EN is not silently blocked forever.
public static func isHostHeavy(defaults: UserDefaults? = nil) -> Bool {
- resolvedDefaults(defaults).bool(forKey: FlowSessionKeys.hostHeavy)
+ let store = resolvedDefaults(defaults)
+ guard store.bool(forKey: FlowSessionKeys.hostHeavy) else { return false }
+ let markedAt = store.double(forKey: FlowSessionKeys.hostHeavyAt)
+ // Legacy writes had the bool but no timestamp — treat as stale so a
+ // pre-fix sticky flag cannot brick typing after upgrade.
+ guard markedAt > 0 else {
+ clearHostHeavy(defaults: store)
+ flush(store)
+ OSGDiag.log("hostHeavy stale missingAt — cleared \(OSGDiag.memoryTag())", category: "flow")
+ return false
+ }
+ let age = Date().timeIntervalSince1970 - markedAt
+ guard age >= 0, age <= FlowSessionKeys.hostHeavyMaxAge else {
+ clearHostHeavy(defaults: store)
+ flush(store)
+ OSGDiag.log(
+ "hostHeavy stale age=\(Int(age))s — cleared \(OSGDiag.memoryTag())",
+ category: "flow"
+ )
+ return false
+ }
+ return true
+ }
+
+ private static func clearHostHeavy(defaults: UserDefaults) {
+ defaults.set(false, forKey: FlowSessionKeys.hostHeavy)
+ defaults.removeObject(forKey: FlowSessionKeys.hostHeavyAt)
}
/// True when the host has published a fresh ready contract (stricter than heartbeat alone).
@@ -948,6 +984,7 @@ public enum FlowSessionBridge {
store.removeObject(forKey: FlowSessionKeys.pendingHostBundleId)
store.removeObject(forKey: FlowSessionKeys.lastActivityAt)
clearHostReady(defaults: store, notify: false)
+ clearHostHeavy(defaults: store)
flush(store)
}
diff --git a/OSGKeyboardShared/Services/FlowSessionKeys.swift b/OSGKeyboardShared/Services/FlowSessionKeys.swift
index 2e2bba7..7a6b173 100644
--- a/OSGKeyboardShared/Services/FlowSessionKeys.swift
+++ b/OSGKeyboardShared/Services/FlowSessionKeys.swift
@@ -43,6 +43,15 @@ public enum FlowSessionKeys {
/// Host is mid heavy work (Rime/CLM/ASR). Extension should stay on voice
/// and skip typing engine prepare until this clears.
public static let hostHeavy = "flow.hostHeavy.v1"
+ /// Wall-clock timestamp paired with `hostHeavy` (seconds since 1970).
+ /// Lets the keyboard ignore a sticky flag left behind when the host died
+ /// mid-warmup without ever clearing App Group state.
+ public static let hostHeavyAt = "flow.hostHeavyAt.v1"
+
+ /// `hostHeavy` older than this is treated as stale (host likely jetsammed
+ /// or force-quit before `setHostHeavy(false)`). Rime/CLM/ASR bursts are
+ /// expected well under this window.
+ public static let hostHeavyMaxAge: TimeInterval = 120
/// Heartbeat older than this → host is not actively reachable for recording.
public static let heartbeatStaleInterval: TimeInterval = 3
diff --git a/OSGKeyboardShared/Services/PolishPromptComposer.swift b/OSGKeyboardShared/Services/PolishPromptComposer.swift
index 7705689..4420f0a 100644
--- a/OSGKeyboardShared/Services/PolishPromptComposer.swift
+++ b/OSGKeyboardShared/Services/PolishPromptComposer.swift
@@ -211,6 +211,10 @@ public enum PolishPromptComposer {
dictionaryBlock,
useChineseGuidance: useChineseGuidance
)
+ let emojiOverride = emojiOverrideSection(
+ allowsAddedEmoji: style.effectiveAllowsAddedEmoji,
+ useChineseGuidance: useChineseGuidance
+ )
if usesHeavyFunPipeline {
let formatting = useChineseGuidance
? chineseFunFormattingPrompt
@@ -226,6 +230,7 @@ public enum PolishPromptComposer {
\(personality)
\(outputInstruction)
+ \(emojiOverride)
"""
}
@@ -262,6 +267,7 @@ public enum PolishPromptComposer {
fieldHints: context.fieldHints,
useChineseGuidance: true
))用户消息即为待处理的转写文本。只输出处理后的文本。
+ \(emojiOverride)
"""
}
@@ -284,6 +290,7 @@ public enum PolishPromptComposer {
fieldHints: context.fieldHints,
useChineseGuidance: false
))The user message is the transcript to process. Output the processed text only.
+ \(emojiOverride)
"""
}
@@ -346,6 +353,8 @@ public enum PolishPromptComposer {
useChineseGuidance: Bool
) -> String {
let body = PolishStylePackCatalog.runtimePersonality(for: style)
+ // Emoji override is appended at the end of compose(), not here —
+ // it must outrank core R5 and later context premises.
if style.kind == .user {
return useChineseGuidance
? """
@@ -370,6 +379,30 @@ public enum PolishPromptComposer {
"""
}
+ /// When a style opts in, override global R5 so the model may add emoji.
+ /// Placed last in the composed prompt so it wins over core R5.
+ private static func emojiOverrideSection(
+ allowsAddedEmoji: Bool,
+ useChineseGuidance: Bool
+ ) -> String {
+ guard allowsAddedEmoji else { return "" }
+ return useChineseGuidance
+ ? """
+
+ # Emoji 覆盖(本风格开启 · 最终优先级)
+ 本风格允许新增 emoji,优先级高于全局 R5「不新增 emoji」以及上文任何「不要加 emoji」表述。
+ 仅按原文已表达的情绪点缀 0–2 个贴合语气的 emoji;中性安排、正式通知与极短确认词不加。
+ 原文已有 emoji 时只整理文字,不替换、不堆叠。禁止无关装饰与 emoji 墙。
+ """
+ : """
+
+ # Emoji override (enabled for this style · final priority)
+ This style may add emojis and outranks global R5 (“add no emojis”) and any earlier “do not add emojis” guidance.
+ Add 0–2 tone-matching emojis only when the draft already expresses emotion; skip neutral schedules, formal notices, and ultra-short acks.
+ If the draft already has emojis, keep them and do not replace or stack. No decorative spam or emoji walls.
+ """
+ }
+
/// Neutralize envelope-breaking tags inside user-controlled transcript text.
internal static func sanitizeEnvelopeContent(_ text: String) -> String {
let maxCharacters = 16_000
diff --git a/OSGKeyboardShared/Services/PolishingService.swift b/OSGKeyboardShared/Services/PolishingService.swift
index 759b21f..fb82bc0 100644
--- a/OSGKeyboardShared/Services/PolishingService.swift
+++ b/OSGKeyboardShared/Services/PolishingService.swift
@@ -285,7 +285,15 @@ public actor PolishingService {
// One prompt, one model request. Deterministic validation may reject a
// result locally, but it never starts a second polish request.
- let firstCandidate = TranscriptPostProcessor.process(original: trimmed, llmOutput: first)
+ let activeStyle = PolishStylePackCatalog.resolve(
+ id: store.activePolishStyleId,
+ userCatalog: store.polishStyleCatalog
+ )
+ let firstCandidate = TranscriptPostProcessor.process(
+ original: trimmed,
+ llmOutput: first,
+ allowsAddedEmoji: activeStyle.effectiveAllowsAddedEmoji
+ )
let firstViolations = PolishOutputValidator.validate(
input: trimmed,
output: firstCandidate,
diff --git a/OSGKeyboardShared/Services/TranscriptPostProcessor.swift b/OSGKeyboardShared/Services/TranscriptPostProcessor.swift
index e8768bc..7f8aecb 100644
--- a/OSGKeyboardShared/Services/TranscriptPostProcessor.swift
+++ b/OSGKeyboardShared/Services/TranscriptPostProcessor.swift
@@ -169,9 +169,17 @@ public enum TranscriptPostProcessor: Sendable {
// MARK: - Post-LLM pipeline
/// Apply deterministic cleanup and quality gate to LLM output.
- public static func process(original: String, llmOutput: String) -> String {
+ public static func process(
+ original: String,
+ llmOutput: String,
+ allowsAddedEmoji: Bool = false
+ ) -> String {
let trimmedOriginal = original.trimmingCharacters(in: .whitespacesAndNewlines)
- let decision = qualityGate(original: trimmedOriginal, candidate: llmOutput)
+ let decision = qualityGate(
+ original: trimmedOriginal,
+ candidate: llmOutput,
+ allowsAddedEmoji: allowsAddedEmoji
+ )
switch decision {
case .accept(let text):
return text
@@ -191,7 +199,11 @@ public enum TranscriptPostProcessor: Sendable {
/// back when the model returned genuinely unusable output (empty, or
/// pure explanation), and even then we prefer a cleaned candidate
/// over the raw transcript.
- public static func qualityGate(original: String, candidate: String) -> GateDecision {
+ public static func qualityGate(
+ original: String,
+ candidate: String,
+ allowsAddedEmoji: Bool = false
+ ) -> GateDecision {
var text = candidate.trimmingCharacters(in: .whitespacesAndNewlines)
if text.isEmpty {
@@ -201,7 +213,9 @@ public enum TranscriptPostProcessor: Sendable {
text = stripExplanatoryPrefix(from: text)
text = stripPauseMarkers(from: text)
text = unwrapSurroundingQuotes(text)
- text = stripAddedEmojis(original: original, output: text)
+ if !allowsAddedEmoji {
+ text = stripAddedEmojis(original: original, output: text)
+ }
text = repairMidSentenceLineBreaks(text)
text = normalizeWhitespaceAndPunctuation(text)
text = normalizeNumberedLists(text)
diff --git a/OSGKeyboardShared/Typing/TypingAutocapitalization.swift b/OSGKeyboardShared/Typing/TypingAutocapitalization.swift
index 932b6d4..5763135 100644
--- a/OSGKeyboardShared/Typing/TypingAutocapitalization.swift
+++ b/OSGKeyboardShared/Typing/TypingAutocapitalization.swift
@@ -42,20 +42,22 @@ public enum TypingAutocapitalization: Sendable {
private static func needsSentenceCapitalization(_ preceding: String?) -> Bool {
guard let preceding, !preceding.isEmpty else { return true }
- // Walk backward past trailing whitespace / newlines; capitalize when
- // the field is empty or the previous visible character ends a sentence.
+ // Walk backward from the caret. Trailing spaces are ignored; a newline
+ // itself starts a new line (system .sentences behavior, e.g. Notes
+ // after Return). Otherwise capitalize only after a sentence terminator.
var index = preceding.endIndex
- var sawContent = false
while index > preceding.startIndex {
index = preceding.index(before: index)
let character = preceding[index]
- if character.isWhitespace || character.isNewline {
+ if character.isNewline {
+ return true
+ }
+ if character.isWhitespace {
continue
}
- sawContent = true
return isSentenceTerminator(character)
}
- return !sawContent
+ return true
}
private static func isSentenceTerminator(_ character: Character) -> Bool {
diff --git a/OSGKeyboardShared/Typing/TypingSessionController.swift b/OSGKeyboardShared/Typing/TypingSessionController.swift
index b405304..b7e270d 100644
--- a/OSGKeyboardShared/Typing/TypingSessionController.swift
+++ b/OSGKeyboardShared/Typing/TypingSessionController.swift
@@ -68,6 +68,10 @@ public final class TypingSessionController: ObservableObject {
private var shiftPrimedByUser = false
/// True if any key was typed while the current Shift hold was active.
private var typedWhileShiftHeld = false
+ /// Local caret-prefix mirror so autocap survives stale `documentContextBeforeInput`
+ /// (common in Notes). Capped; reseeds from the proxy when it looks fresh.
+ private var precedingShadow = ""
+ private static let precedingShadowLimit = 400
public init(
engine: (@MainActor () -> RimeEngineBridging)? = nil,
@@ -287,6 +291,14 @@ public final class TypingSessionController: ObservableObject {
return handleEnglishCharacter(ch)
}
+ // Chinese + Shift: insert Latin directly (iOS-style mix-in), leave Rime
+ // composition untouched. Rime's alphabet is lowercase-only, so uppercase
+ // keycodes would otherwise be rejected with no output.
+ if isShiftEnabled, ch.isLetter {
+ clearOneShotShiftIfNeeded()
+ return .insert(String(ch))
+ }
+
// Chinese letters → compose
let committed = engine.processCharacter(ch) ?? ""
composition = engine.composition
@@ -490,17 +502,94 @@ public final class TypingSessionController: ObservableObject {
/// Arms Shift for sentence / word starts using the host field traits.
/// Manual one-shot, hold, and Caps Lock always win over autocapitalization.
- public func syncAutocapitalization() {
+ /// - Parameters:
+ /// - insert: Text just written through the document proxy (may not be
+ /// reflected in `documentContextBeforeInput` yet).
+ /// - deleteCount: Characters just deleted before `insert` (replace path).
+ public func syncAutocapitalization(
+ accountingForInsert insert: String = "",
+ deleteCount: Int = 0
+ ) {
guard language == .english, page == .letters else { return }
guard !capsLock, !shiftHeld, !shiftPrimedByUser else { return }
let mode = autocapitalizationModeProvider?() ?? .sentences
- let preceding = precedingTextProvider?()
+ let preceding = resolvedPrecedingText(
+ accountingForInsert: insert,
+ deleteCount: deleteCount
+ )
shiftActive = TypingAutocapitalization.shouldCapitalize(
precedingText: preceding,
mode: mode
)
}
+ /// Prefer a fresh proxy; when the host lags (Notes), merge our just-applied edit.
+ private func resolvedPrecedingText(
+ accountingForInsert insert: String,
+ deleteCount: Int
+ ) -> String? {
+ // Host-driven refresh (appear / textDidChange): reseed from proxy.
+ if deleteCount == 0, insert.isEmpty {
+ if let proxy = precedingTextProvider?() {
+ return storePrecedingShadow(proxy)
+ }
+ return precedingShadow.isEmpty ? nil : precedingShadow
+ }
+
+ let proxy = precedingTextProvider?()
+
+ if deleteCount > 0, !insert.isEmpty {
+ if let proxy {
+ if proxy.hasSuffix(insert) {
+ return storePrecedingShadow(proxy)
+ }
+ if proxy.count >= deleteCount {
+ return storePrecedingShadow(String(proxy.dropLast(deleteCount)) + insert)
+ }
+ }
+ trimPrecedingShadow(by: deleteCount)
+ return storePrecedingShadow(precedingShadow + insert)
+ }
+
+ if deleteCount > 0 {
+ if let proxy, proxy.count + deleteCount == precedingShadow.count
+ || (precedingShadow.count >= deleteCount
+ && String(precedingShadow.dropLast(deleteCount)) == proxy) {
+ return storePrecedingShadow(proxy)
+ }
+ if precedingShadow.count >= deleteCount {
+ return storePrecedingShadow(String(precedingShadow.dropLast(deleteCount)))
+ }
+ if let proxy { return storePrecedingShadow(proxy) }
+ precedingShadow = ""
+ return ""
+ }
+
+ // insert only
+ if let proxy {
+ if proxy.hasSuffix(insert) {
+ return storePrecedingShadow(proxy)
+ }
+ return storePrecedingShadow(proxy + insert)
+ }
+ return storePrecedingShadow(precedingShadow + insert)
+ }
+
+ @discardableResult
+ private func storePrecedingShadow(_ value: String) -> String {
+ precedingShadow = String(value.suffix(Self.precedingShadowLimit))
+ return precedingShadow
+ }
+
+ private func trimPrecedingShadow(by count: Int) {
+ guard count > 0 else { return }
+ if precedingShadow.count >= count {
+ precedingShadow.removeLast(count)
+ } else {
+ precedingShadow = ""
+ }
+ }
+
// MARK: - Shift
/// Tap cycle: off → one-shot → Caps Lock → off (iOS-style second tap).
@@ -536,6 +625,7 @@ public final class TypingSessionController: ObservableObject {
shiftHeld = false
shiftPrimedByUser = false
typedWhileShiftHeld = false
+ precedingShadow = ""
}
private func clearEnglishWordState(keepPrevious: Bool) {
diff --git a/OSGKeyboardShared/en.lproj/Shared.strings b/OSGKeyboardShared/en.lproj/Shared.strings
index deec117..23529aa 100644
--- a/OSGKeyboardShared/en.lproj/Shared.strings
+++ b/OSGKeyboardShared/en.lproj/Shared.strings
@@ -158,6 +158,8 @@
"mac.styles.error" = "Couldn’t Save Style";
"mac.styles.validation" = "Check the name, prompt length, and the 8-style limit.";
"mac.styles.name" = "Style name";
+"mac.styles.allowsAddedEmoji" = "Allow mood emojis";
+"mac.styles.allowsAddedEmoji.hint" = "When on, polish may add a few emojis that match the draft’s emotion, and keeps them on screen. Off by default.";
"mac.styles.prompt" = "Complete prompt";
"mac.styles.hint" = "Use {{DICTIONARY}} to place the personal dictionary. System rules are appended automatically.";
"mac.section.settings" = "Settings";
@@ -262,8 +264,11 @@
"mac.settings.translationOff" = "Don't translate";
"mac.settings.volcengineAppId" = "APP ID";
"mac.settings.volcengineAccessToken" = "Access Token";
-"mac.settings.volcengineResourceId" = "Resource ID";
-"mac.settings.volcengineNote" = "Secret Key is not required. Resource ID defaults to volc.seedasr.sauc.duration.";
+"mac.settings.volcengineApiKey" = "API Key";
+"mac.settings.volcengineApiKeyMode" = "New API Key auth";
+"mac.settings.volcengineApiKeyModeSubtitle" = "On for the new console; off keeps APP ID + Access Token.";
+"mac.settings.volcengineNoteAppToken" = "Legacy console: APP ID + Access Token. Secret Key not required. Resource fixed to Doubao streaming 2.0 (volc.seedasr.sauc.duration).";
+"mac.settings.volcengineNoteApiKey" = "New console: API Key only. Resource fixed to Doubao streaming 2.0 (volc.seedasr.sauc.duration).";
"mac.settings.recognition" = "RECOGNITION METHOD";
"mac.settings.cloudEngine" = "Cloud Engine & AI Refinement";
"mac.settings.cloudEngineDesc" = "Premium transcription via your provider's API, plus AI grammar and style polishing.";
diff --git a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings
index 5946f5e..0bb2fe5 100644
--- a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings
+++ b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings
@@ -157,6 +157,8 @@
"mac.styles.error" = "无法保存风格";
"mac.styles.validation" = "请检查名称、提示词长度及 8 个风格的数量上限。";
"mac.styles.name" = "风格名称";
+"mac.styles.allowsAddedEmoji" = "允许按情绪添加 emoji";
+"mac.styles.allowsAddedEmoji.hint" = "开启后,润色可按原文情绪点缀少量 emoji,并保留上屏。默认关闭。";
"mac.styles.prompt" = "完整提示词";
"mac.styles.hint" = "使用 {{DICTIONARY}} 指定个人词典位置;系统规则会自动追加。";
"mac.section.settings" = "设置";
@@ -261,8 +263,11 @@
"mac.settings.translationOff" = "不翻译";
"mac.settings.volcengineAppId" = "APP ID";
"mac.settings.volcengineAccessToken" = "Access Token";
-"mac.settings.volcengineResourceId" = "Resource ID";
-"mac.settings.volcengineNote" = "Secret Key 当前无需填写。Resource ID 默认使用 volc.seedasr.sauc.duration。";
+"mac.settings.volcengineApiKey" = "API Key";
+"mac.settings.volcengineApiKeyMode" = "使用新版 API Key 鉴权";
+"mac.settings.volcengineApiKeyModeSubtitle" = "新控制台请打开;已有 AppID + Token 可保持关闭。";
+"mac.settings.volcengineNoteAppToken" = "旧版控制台:填写 APP ID 与 Access Token。Secret Key 无需填写。识别资源固定为豆包流式 2.0(volc.seedasr.sauc.duration)。";
+"mac.settings.volcengineNoteApiKey" = "新版控制台:只需填写 API Key。识别资源固定为豆包流式 2.0(volc.seedasr.sauc.duration)。";
"mac.settings.recognition" = "识别方式";
"mac.settings.cloudEngine" = "云端引擎与 AI 润色";
"mac.settings.cloudEngineDesc" = "通过服务商 API 进行高质量转写,并自动润色语法与风格。";
diff --git a/OSGKeyboardTests/CloudASRTests.swift b/OSGKeyboardTests/CloudASRTests.swift
index 707a04b..73199e1 100644
--- a/OSGKeyboardTests/CloudASRTests.swift
+++ b/OSGKeyboardTests/CloudASRTests.swift
@@ -130,7 +130,10 @@ final class CloudASRTests: XCTestCase {
let fields = VolcengineASRFields.parse(apiKey: json, resourceFallback: "")
XCTAssertEqual(fields.appID, "app-1")
XCTAssertEqual(fields.accessToken, "tok-2")
- XCTAssertEqual(fields.resourceID, "res-3")
+ XCTAssertEqual(fields.authMode, .appToken)
+ // Custom resource IDs are ignored; product is locked to SAUC 2.0 duration.
+ XCTAssertEqual(fields.resourceID, VolcengineASRFields.fixedResourceID)
+ XCTAssertTrue(fields.hasUsableCredentials)
}
func testVolcengineASRFieldsColonParsing() {
@@ -140,8 +143,70 @@ final class CloudASRTests: XCTestCase {
)
XCTAssertEqual(fields.appID, "app-1")
XCTAssertEqual(fields.accessToken, "tok-2")
- XCTAssertEqual(fields.resourceID, "res-3")
+ XCTAssertEqual(fields.authMode, .appToken)
+ XCTAssertEqual(fields.resourceID, VolcengineASRFields.fixedResourceID)
XCTAssertTrue(fields.encodedAPIKey.contains("app-1"))
+ XCTAssertTrue(fields.encodedAPIKey.contains("auth_mode"))
+ }
+
+ func testVolcengineASRFieldsAPIKeyModeParsing() {
+ let json = #"{"auth_mode":"api_key","api_key":"vk-new-console"}"#
+ let fields = VolcengineASRFields.parse(apiKey: json)
+ XCTAssertEqual(fields.authMode, .apiKey)
+ XCTAssertEqual(fields.apiKeyCredential, "vk-new-console")
+ XCTAssertTrue(fields.hasUsableCredentials)
+ XCTAssertEqual(fields.resourceID, VolcengineASRFields.fixedResourceID)
+
+ var request = URLRequest(url: URL(string: "wss://example.invalid")!)
+ fields.applyWebSocketAuthHeaders(to: &request, connectID: "conn-1")
+ XCTAssertEqual(request.value(forHTTPHeaderField: "X-Api-Key"), "vk-new-console")
+ XCTAssertNil(request.value(forHTTPHeaderField: "X-Api-App-Key"))
+ XCTAssertNil(request.value(forHTTPHeaderField: "X-Api-Access-Key"))
+ XCTAssertEqual(
+ request.value(forHTTPHeaderField: "X-Api-Resource-Id"),
+ VolcengineASRFields.fixedResourceID
+ )
+ XCTAssertEqual(request.value(forHTTPHeaderField: "X-Api-Connect-Id"), "conn-1")
+ }
+
+ func testVolcengineASRFieldsAppTokenHeaders() {
+ let fields = VolcengineASRFields(
+ authMode: .appToken,
+ appID: "app-1",
+ accessToken: "tok-2"
+ )
+ var request = URLRequest(url: URL(string: "wss://example.invalid")!)
+ fields.applyWebSocketAuthHeaders(to: &request, connectID: "conn-2")
+ XCTAssertEqual(request.value(forHTTPHeaderField: "X-Api-App-Key"), "app-1")
+ XCTAssertEqual(request.value(forHTTPHeaderField: "X-Api-Access-Key"), "tok-2")
+ XCTAssertNil(request.value(forHTTPHeaderField: "X-Api-Key"))
+ XCTAssertEqual(
+ request.value(forHTTPHeaderField: "X-Api-Resource-Id"),
+ VolcengineASRFields.fixedResourceID
+ )
+ }
+
+ func testVolcengineASRFieldsTogglePreservesBothCredentialSets() {
+ var fields = VolcengineASRFields(
+ authMode: .appToken,
+ appID: "app-1",
+ accessToken: "tok-2",
+ apiKeyCredential: "vk-keep"
+ )
+ fields.authMode = .apiKey
+ let encoded = fields.encodedAPIKey
+ let parsed = VolcengineASRFields.parse(apiKey: encoded)
+ XCTAssertEqual(parsed.authMode, .apiKey)
+ XCTAssertEqual(parsed.apiKeyCredential, "vk-keep")
+ XCTAssertEqual(parsed.appID, "app-1")
+ XCTAssertEqual(parsed.accessToken, "tok-2")
+ }
+
+ func testVolcengineASRFieldsEmptyAPIKeyModeIsNotUsable() {
+ let json = #"{"auth_mode":"api_key"}"#
+ let fields = VolcengineASRFields.parse(apiKey: json)
+ XCTAssertEqual(fields.authMode, .apiKey)
+ XCTAssertFalse(fields.hasUsableCredentials)
}
func testPersonalDictionaryASRHotwordsDedupesTerms() {
diff --git a/OSGKeyboardTests/FlowKeyboardPoliciesTests.swift b/OSGKeyboardTests/FlowKeyboardPoliciesTests.swift
index f410e60..d498f6b 100644
--- a/OSGKeyboardTests/FlowKeyboardPoliciesTests.swift
+++ b/OSGKeyboardTests/FlowKeyboardPoliciesTests.swift
@@ -42,6 +42,90 @@ final class FlowKeyboardPoliciesTests: XCTestCase {
XCTAssertTrue(warming)
}
+ func testRecentReadyGraceDoesNotForcePreparingSession() {
+ // withinReadyGrace used to OR into warming and flash yellow after each
+ // utterance; sticky ready must hold green instead.
+ let warming = FlowKeyboardHostWarming.isHostWarming(
+ hostReady: false,
+ hostBusy: false,
+ sessionActive: true,
+ hostReachable: false,
+ isPendingFlowStart: false,
+ withinReadyGrace: true,
+ snapshotReason: .starting
+ )
+ // Still warming via reason=.starting when not yet proven ready on the
+ // keyboard side; grace alone must not be the trigger.
+ XCTAssertTrue(warming)
+
+ XCTAssertTrue(
+ FlowKeyboardHostWarming.shouldHoldReady(
+ hostReady: false,
+ hostBusy: false,
+ sessionActive: true,
+ sessionProvenReady: true,
+ isPendingFlowStart: false,
+ snapshotReason: .starting
+ ),
+ "proven-ready session must hold green through starting flaps"
+ )
+ XCTAssertTrue(
+ FlowKeyboardHostWarming.shouldHoldReady(
+ hostReady: false,
+ hostBusy: true,
+ sessionActive: true,
+ sessionProvenReady: true,
+ isPendingFlowStart: false,
+ snapshotReason: .awaitingDelivery
+ ),
+ "ack lag awaitingDelivery must not drop to preparingSession"
+ )
+ XCTAssertFalse(
+ FlowKeyboardHostWarming.shouldHoldReady(
+ hostReady: false,
+ hostBusy: true,
+ sessionActive: true,
+ sessionProvenReady: true,
+ isPendingFlowStart: false,
+ snapshotReason: .recording
+ ),
+ "live recording must not be masked as sticky ready"
+ )
+ XCTAssertFalse(
+ FlowKeyboardHostWarming.shouldHoldReady(
+ hostReady: false,
+ hostBusy: false,
+ sessionActive: true,
+ sessionProvenReady: false,
+ isPendingFlowStart: true,
+ snapshotReason: .starting
+ ),
+ "cold start pending must still show preparing"
+ )
+ }
+
+ func testHeldReadySuppressesWarming() {
+ let hold = FlowKeyboardHostWarming.shouldHoldReady(
+ hostReady: false,
+ hostBusy: false,
+ sessionActive: true,
+ sessionProvenReady: true,
+ isPendingFlowStart: false,
+ snapshotReason: .starting
+ )
+ XCTAssertTrue(hold)
+ let warming = FlowKeyboardHostWarming.isHostWarming(
+ hostReady: true, // effective ready after hold
+ hostBusy: false,
+ sessionActive: true,
+ hostReachable: true,
+ isPendingFlowStart: false,
+ withinReadyGrace: true,
+ snapshotReason: .starting
+ )
+ XCTAssertFalse(warming)
+ }
+
// MARK: - Adopt busy
func testReAdoptsRecordingAfterExtensionProcessLoss() {
diff --git a/OSGKeyboardTests/FlowReliabilityTests.swift b/OSGKeyboardTests/FlowReliabilityTests.swift
index 047fa74..abb08de 100644
--- a/OSGKeyboardTests/FlowReliabilityTests.swift
+++ b/OSGKeyboardTests/FlowReliabilityTests.swift
@@ -220,4 +220,14 @@ final class FlowReliabilityTests: XCTestCase {
)
)
}
+
+ func testCaptureVoiceProcessingUsesSpeechOrientedSessionMode() {
+ XCTAssertEqual(FlowCaptureVoiceProcessing.captureMode, .voiceChat)
+ XCTAssertTrue(
+ FlowCaptureVoiceProcessing.captureOptions.contains(.allowBluetoothHFP)
+ )
+ XCTAssertTrue(
+ FlowCaptureVoiceProcessing.captureOptions.contains(.defaultToSpeaker)
+ )
+ }
}
diff --git a/OSGKeyboardTests/FlowSessionBridgeTests.swift b/OSGKeyboardTests/FlowSessionBridgeTests.swift
index 00853b4..1f22a8e 100644
--- a/OSGKeyboardTests/FlowSessionBridgeTests.swift
+++ b/OSGKeyboardTests/FlowSessionBridgeTests.swift
@@ -428,6 +428,41 @@ final class FlowSessionBridgeTests: XCTestCase {
)
}
+ func testHostHeavyClearsOnFlowStateReset() {
+ let defaults = makeDefaults()
+ FlowSessionBridge.setHostHeavy(true, defaults: defaults)
+ XCTAssertTrue(FlowSessionBridge.isHostHeavy(defaults: defaults))
+
+ FlowSessionBridge.clearFlowState(defaults: defaults)
+ XCTAssertFalse(FlowSessionBridge.isHostHeavy(defaults: defaults))
+
+ FlowSessionBridge.setHostHeavy(true, defaults: defaults)
+ FlowSessionBridge.setPendingHostBundleId("com.example.host", defaults: defaults)
+ FlowSessionBridge.clearFlowStateOnHostLaunch(defaults: defaults)
+ XCTAssertFalse(FlowSessionBridge.isHostHeavy(defaults: defaults))
+ XCTAssertEqual(
+ FlowSessionBridge.pendingHostBundleId(defaults: defaults),
+ "com.example.host"
+ )
+ }
+
+ func testStaleHostHeavyDoesNotBlockTyping() {
+ let defaults = makeDefaults()
+ // Legacy sticky bool with no timestamp — must not brick 中文/EN.
+ defaults.set(true, forKey: FlowSessionKeys.hostHeavy)
+ XCTAssertFalse(FlowSessionBridge.isHostHeavy(defaults: defaults))
+ XCTAssertFalse(defaults.bool(forKey: FlowSessionKeys.hostHeavy))
+
+ FlowSessionBridge.setHostHeavy(true, defaults: defaults)
+ XCTAssertTrue(FlowSessionBridge.isHostHeavy(defaults: defaults))
+
+ // Expired timestamp → treat as clear so cold keyboard can switch.
+ let expired = Date().timeIntervalSince1970 - FlowSessionKeys.hostHeavyMaxAge - 1
+ defaults.set(expired, forKey: FlowSessionKeys.hostHeavyAt)
+ XCTAssertFalse(FlowSessionBridge.isHostHeavy(defaults: defaults))
+ XCTAssertFalse(defaults.bool(forKey: FlowSessionKeys.hostHeavy))
+ }
+
func testRotateHostGenerationReturnsPreviousToken() {
let defaults = makeDefaults()
XCTAssertNil(FlowSessionBridge.rotateHostGeneration(defaults: defaults))
diff --git a/OSGKeyboardTests/IntelligentPolishTests.swift b/OSGKeyboardTests/IntelligentPolishTests.swift
index f9fc110..d72a7ff 100644
--- a/OSGKeyboardTests/IntelligentPolishTests.swift
+++ b/OSGKeyboardTests/IntelligentPolishTests.swift
@@ -340,6 +340,54 @@ final class IntelligentPolishTests: XCTestCase {
XCTAssertTrue(result.contains("完成"))
}
+ func testPolishServiceKeepsAddedEmojiWhenStyleAllows() async throws {
+ store.setEngineMode("local")
+ var catalog = PolishStyleCatalog()
+ let pack = PolishStylePack(
+ id: "user.emoji",
+ name: "Emoji",
+ prompt: "保持口语,可按情绪加 emoji。",
+ allowsAddedEmoji: true
+ )
+ try catalog.upsert(pack)
+ store.setPolishStyleCatalog(catalog)
+ store.setActivePolishStyleId(pack.id)
+
+ let emojiClient = FixedResponseLLMClient(response: "今天太开心了,终于搞定了😆")
+ let service = PolishingService(store: store, client: emojiClient)
+ let result = try await service.polish(
+ "今天太开心了终于搞定了",
+ context: PolishContext()
+ )
+ XCTAssertTrue(result.contains("😆"), "Allowed-emoji styles must keep model-added emoji. Got: \(result)")
+ XCTAssertTrue(result.contains("开心"))
+ }
+
+ func testPolishServiceKeepsAddedEmojiWhenPromptOptsInWithoutToggle() async throws {
+ store.setEngineMode("local")
+ var catalog = PolishStyleCatalog()
+ let pack = PolishStylePack(
+ id: "user.paste-emoji",
+ name: "PasteEmoji",
+ prompt: "本风格允许新增 emoji。按情绪点缀合适表情。",
+ allowsAddedEmoji: false
+ )
+ try catalog.upsert(pack)
+ store.setPolishStyleCatalog(catalog)
+ store.setActivePolishStyleId(pack.id)
+
+ let emojiClient = FixedResponseLLMClient(response: "辛苦你了,真的谢谢🙏")
+ let service = PolishingService(store: store, client: emojiClient)
+ let result = try await service.polish(
+ "辛苦你了真的谢谢",
+ context: PolishContext()
+ )
+ XCTAssertTrue(
+ result.contains("🙏"),
+ "Prompt opt-in must keep emoji even when toggle is off. Got: \(result)"
+ )
+ }
+
func testPolishServiceFallsBackWhenOutputEmpty() async throws {
store.setEngineMode("local")
let emptyClient = FixedResponseLLMClient(response: " ")
@@ -410,6 +458,30 @@ final class IntelligentPolishTests: XCTestCase {
XCTAssertEqual(result, "好的")
}
+ func testQualityGateKeepsAddedEmojiWhenAllowed() {
+ let decision = TranscriptPostProcessor.qualityGate(
+ original: "今天太开心了",
+ candidate: "今天太开心了😆",
+ allowsAddedEmoji: true
+ )
+ guard case .accept(let text) = decision else {
+ return XCTFail("Expected accept")
+ }
+ XCTAssertTrue(text.contains("😆"))
+ }
+
+ func testQualityGateStripsAddedEmojiByDefault() {
+ let decision = TranscriptPostProcessor.qualityGate(
+ original: "今天太开心了",
+ candidate: "今天太开心了😆",
+ allowsAddedEmoji: false
+ )
+ guard case .accept(let text) = decision else {
+ return XCTFail("Expected accept")
+ }
+ XCTAssertFalse(text.contains("😆"))
+ }
+
func testQualityGateStripsResidualPauseMarkers() {
let result = TranscriptPostProcessor.process(
original: "第一段 ⟨0.8s⟩ 第二段",
diff --git a/OSGKeyboardTests/PolishStylePackTests.swift b/OSGKeyboardTests/PolishStylePackTests.swift
index 56dca0d..2d3ee90 100644
--- a/OSGKeyboardTests/PolishStylePackTests.swift
+++ b/OSGKeyboardTests/PolishStylePackTests.swift
@@ -206,6 +206,90 @@ final class PolishStylePackTests: XCTestCase {
XCTAssertTrue(prompt.contains("词典命中优先于同音猜测"))
XCTAssertTrue(prompt.contains("风格接入(纠错之后)"))
XCTAssertFalse(prompt.contains("原始内容"))
+ XCTAssertFalse(prompt.contains("Emoji 覆盖"))
+ }
+
+ func testComposerInjectsEmojiOverrideWhenStyleAllows() {
+ let style = PolishStylePack(
+ id: "user.emoji",
+ name: "Emoji",
+ prompt: "ROLE",
+ allowsAddedEmoji: true
+ )
+ let prompt = PolishPromptComposer.compose(
+ text: "今天太开心了",
+ style: style,
+ context: PolishContext(),
+ dictionaryBlock: "",
+ useChineseGuidance: true
+ )
+ XCTAssertTrue(prompt.contains("Emoji 覆盖(本风格开启 · 最终优先级)"))
+ XCTAssertTrue(prompt.contains("优先级高于全局 R5"))
+ // Override must appear after core R5 so it wins.
+ let r5 = prompt.range(of: "R5 不新增 emoji")
+ let override = prompt.range(of: "Emoji 覆盖(本风格开启 · 最终优先级)")
+ XCTAssertNotNil(r5)
+ XCTAssertNotNil(override)
+ XCTAssertLessThan(r5!.lowerBound, override!.lowerBound)
+ }
+
+ func testPromptOptInEnablesEmojiWithoutToggle() async throws {
+ let style = PolishStylePack(
+ id: "user.paste",
+ name: "Paste",
+ prompt: """
+ # 最高优先级覆盖
+ 本风格允许新增 emoji。当与全局「不新增 emoji」规则冲突时,以本风格为准。
+ """,
+ allowsAddedEmoji: false
+ )
+ XCTAssertTrue(style.effectiveAllowsAddedEmoji)
+
+ let composed = PolishPromptComposer.compose(
+ text: "今天太开心了",
+ style: style,
+ context: PolishContext(),
+ dictionaryBlock: "",
+ useChineseGuidance: true
+ )
+ XCTAssertTrue(composed.contains("Emoji 覆盖(本风格开启 · 最终优先级)"))
+ }
+
+ func testLegacyPackDecodeDefaultsAllowsAddedEmojiToFalse() throws {
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ // Encode without the new key by decoding a minimal legacy payload.
+ let legacyJSON = """
+ {
+ "id": "user.legacy",
+ "name": "Legacy",
+ "prompt": "ROLE",
+ "kind": "user",
+ "createdAt": 0,
+ "updatedAt": 0
+ }
+ """
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .secondsSince1970
+ let pack = try decoder.decode(PolishStylePack.self, from: Data(legacyJSON.utf8))
+ XCTAssertFalse(pack.allowsAddedEmoji)
+ }
+
+ func testUpsertPreservesAllowsAddedEmoji() throws {
+ var catalog = PolishStyleCatalog()
+ let pack = PolishStylePack(
+ name: "Emoji",
+ prompt: "ROLE",
+ allowsAddedEmoji: true
+ )
+ try catalog.upsert(pack)
+ XCTAssertEqual(catalog.entries.first?.allowsAddedEmoji, true)
+
+ var updated = pack
+ updated.prompt = "ROLE 2"
+ try catalog.upsert(updated)
+ XCTAssertEqual(catalog.entries.first?.allowsAddedEmoji, true)
+ XCTAssertEqual(catalog.entries.first?.prompt, "ROLE 2")
}
func testComposerKeepsHomophoneRepairWhenDictionaryPresent() {
diff --git a/docs/keyboard-memory-budget.md b/docs/keyboard-memory-budget.md
index 122a677..a943ed4 100644
--- a/docs/keyboard-memory-budget.md
+++ b/docs/keyboard-memory-budget.md
@@ -13,7 +13,10 @@ wiring; jetsam behavior is device-only.
- **Do not** stack CLM + Rime + ASR in the same second after onboarding.
- ASR warmup runs on **first mic press** (`beginUtterance`), gated by
`HostMemoryBudget` (~260 MB RSS). `hostHeavy` is set only while heavy work
- actually runs, then cleared.
+ actually runs, then cleared. A sticky `hostHeavy` (host died mid-work)
+ expires after `hostHeavyMaxAge` (~120 s) and is cleared on
+ `clearFlowState` / host-launch reconciliation so typing 中文/EN is not
+ permanently blocked.
## Console checklist
diff --git a/project.yml b/project.yml
index 057edc7..52850b0 100644
--- a/project.yml
+++ b/project.yml
@@ -51,8 +51,8 @@ settings:
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: YES
STRING_CATALOG_GENERATE_SYMBOLS: YES
CLANG_CXX_LANGUAGE_STANDARD: c++17
- MARKETING_VERSION: "1.6.1"
- CURRENT_PROJECT_VERSION: "45"
+ MARKETING_VERSION: "1.6.2"
+ CURRENT_PROJECT_VERSION: "47"
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
# 项目级签名 xcconfig,适用于所有 target