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
@@ -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)