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:
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user