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.
This commit is contained in:
Rocky
2026-08-06 15:11:12 +08:00
parent 2ed16e1fbf
commit a8d58d8f0c
47 changed files with 1467 additions and 258 deletions
@@ -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 {
+1 -1
View File
@@ -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(
@@ -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)
}
@@ -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)
}
@@ -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 {
@@ -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 yelloweven 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
)
}
@@ -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)
}
@@ -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
@@ -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 02 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
@@ -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,
@@ -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)
@@ -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 {
@@ -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) {
+7 -2
View File
@@ -158,6 +158,8 @@
"mac.styles.error" = "Couldnt 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 drafts 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.";
@@ -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.0volc.seedasr.sauc.duration)。";
"mac.settings.volcengineNoteApiKey" = "新版控制台:只需填写 API Key。识别资源固定为豆包流式 2.0volc.seedasr.sauc.duration)。";
"mac.settings.recognition" = "识别方式";
"mac.settings.cloudEngine" = "云端引擎与 AI 润色";
"mac.settings.cloudEngineDesc" = "通过服务商 API 进行高质量转写,并自动润色语法与风格。";