a8d58d8f0c
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.
198 lines
6.5 KiB
Swift
198 lines
6.5 KiB
Swift
// FlowKeyboardPolicies.swift
|
|
// OSGKeyboard · Shared
|
|
//
|
|
// Pure decision helpers extracted from KeyboardFlowCoordinator so mic
|
|
// warming / host re-adopt / result matching stay hermetic and regression-tested.
|
|
|
|
import Foundation
|
|
|
|
// MARK: - Host warming (orange "starting" vs busy)
|
|
|
|
public enum FlowKeyboardHostWarming {
|
|
/// Host is mid-utterance — never treat as "still starting".
|
|
public static func isHostBusy(reason: FlowReadySnapshot.Reason?) -> Bool {
|
|
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,
|
|
sessionActive: Bool,
|
|
hostReachable: Bool,
|
|
isPendingFlowStart: Bool,
|
|
withinReadyGrace: Bool,
|
|
snapshotReason: FlowReadySnapshot.Reason?
|
|
) -> Bool {
|
|
_ = withinReadyGrace
|
|
return !hostReady
|
|
&& !hostBusy
|
|
&& sessionActive
|
|
&& (
|
|
hostReachable
|
|
|| isPendingFlowStart
|
|
|| snapshotReason == .starting
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Re-adopt busy host after extension jetsam
|
|
|
|
public enum FlowKeyboardAdoptBusyAction: Equatable, Sendable {
|
|
case none
|
|
case clearStickyProcessing
|
|
case adoptRecording(sessionId: UUID, utteranceId: UUID)
|
|
case adoptProcessing(sessionId: UUID, utteranceId: UUID)
|
|
}
|
|
|
|
public enum FlowKeyboardAdoptBusyPolicy {
|
|
public static func decide(
|
|
snapshot: FlowReadySnapshot?,
|
|
currentHostGeneration: String?,
|
|
isFlowRecording: Bool,
|
|
isAwaitingFlowResult: Bool,
|
|
lastConsumedUtteranceId: UUID?,
|
|
lastStoppedUtteranceId: UUID?
|
|
) -> FlowKeyboardAdoptBusyAction {
|
|
guard let snapshot, let sessionId = snapshot.sessionId else { return .none }
|
|
|
|
if let snapGen = snapshot.hostGeneration,
|
|
let liveGen = currentHostGeneration,
|
|
snapGen != liveGen {
|
|
return .none
|
|
}
|
|
|
|
if snapshot.reason != .recording, snapshot.reason != .processing {
|
|
return .clearStickyProcessing
|
|
}
|
|
|
|
switch snapshot.reason {
|
|
case .recording:
|
|
guard !isFlowRecording else { return .none }
|
|
guard !isAwaitingFlowResult else { return .none }
|
|
guard let busyId = snapshot.busyUtteranceId else { return .none }
|
|
guard busyId != lastConsumedUtteranceId else { return .none }
|
|
guard busyId != lastStoppedUtteranceId else { return .none }
|
|
return .adoptRecording(sessionId: sessionId, utteranceId: busyId)
|
|
case .processing:
|
|
guard !isAwaitingFlowResult else { return .none }
|
|
guard let busyId = snapshot.busyUtteranceId else { return .none }
|
|
guard busyId != lastConsumedUtteranceId else { return .none }
|
|
return .adoptProcessing(sessionId: sessionId, utteranceId: busyId)
|
|
default:
|
|
return .none
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Result matching
|
|
|
|
public enum FlowKeyboardResultMatcher {
|
|
public static func matchingResult(
|
|
latest: FlowResult?,
|
|
activeSessionId: UUID?,
|
|
currentUtteranceId: UUID?,
|
|
currentHostGeneration: String? = nil
|
|
) -> FlowResult? {
|
|
guard let latest, let activeSessionId, let currentUtteranceId else { return nil }
|
|
if let resultGeneration = latest.hostGeneration,
|
|
let currentHostGeneration,
|
|
resultGeneration != currentHostGeneration {
|
|
return nil
|
|
}
|
|
guard latest.sessionId == activeSessionId,
|
|
latest.utteranceId == currentUtteranceId else {
|
|
return nil
|
|
}
|
|
return latest
|
|
}
|
|
|
|
public static func isTerminalFailure(_ result: FlowResult) -> Bool {
|
|
result.status == .error || result.status == .timeout || result.status == .aborted
|
|
}
|
|
}
|
|
|
|
// MARK: - Host command gate
|
|
|
|
public enum FlowCommandGateDecision: Equatable, Sendable {
|
|
case accept
|
|
case rejectWrongSession
|
|
case rejectStaleSeq
|
|
}
|
|
|
|
public enum FlowCommandGatekeeper {
|
|
public static func decide(
|
|
commandSessionId: UUID,
|
|
commandSeq: Int64,
|
|
activeSessionId: UUID?,
|
|
lastHandledCommandSeq: Int64
|
|
) -> FlowCommandGateDecision {
|
|
guard let activeSessionId, commandSessionId == activeSessionId else {
|
|
return .rejectWrongSession
|
|
}
|
|
guard commandSeq > lastHandledCommandSeq else {
|
|
return .rejectStaleSeq
|
|
}
|
|
return .accept
|
|
}
|
|
}
|
|
|
|
// MARK: - Orphan recording before host start
|
|
|
|
public enum FlowOrphanRecordingDecision: Equatable, Sendable {
|
|
case none
|
|
case clearZombieSession
|
|
case clearOrphanedRecording(FlowSessionKeys.RecordingState)
|
|
}
|
|
|
|
public enum FlowOrphanRecordingReconciler {
|
|
public static func decide(
|
|
isHostStale: Bool,
|
|
isActive: Bool,
|
|
recordingState: FlowSessionKeys.RecordingState
|
|
) -> FlowOrphanRecordingDecision {
|
|
if isHostStale {
|
|
return .clearZombieSession
|
|
}
|
|
guard !isActive else { return .none }
|
|
switch recordingState {
|
|
case .recording, .stopped, .processing:
|
|
return .clearOrphanedRecording(recordingState)
|
|
case .idle, .aborted:
|
|
return .none
|
|
}
|
|
}
|
|
}
|