feat(app): add resilient Flow recovery and privacy-safe analytics
CI / Validate manifests (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
CI / iOS / Extension (push) Has been cancelled
CI / macOS (push) Has been cancelled

Unify PiP recovery across startup and foreground transitions, surface actionable status, and add privacy-safe analytics plus the refreshed onboarding and account experience. Update documentation assets and advance the release build to 84.
This commit is contained in:
Rocky
2026-08-20 20:22:20 +08:00
parent ee7de5c934
commit 10bc457c72
69 changed files with 7891 additions and 598 deletions
@@ -114,22 +114,33 @@ public struct AIQuestionService: Sendable {
private let client: any LLMClient
private let conversations: AIConversationStore
private let responseLength: AIResponseLength
private let analyticsClient: any AnalyticsClient
private let analyticsFeature: AnalyticsFeature
private let analyticsExecutionMode: AnalyticsExecutionMode
public init(
client: any LLMClient,
conversations: AIConversationStore,
responseLength: AIResponseLength = .default
responseLength: AIResponseLength = .default,
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
analyticsFeature: AnalyticsFeature = .aiAssistant,
analyticsExecutionMode: AnalyticsExecutionMode = .byok
) {
self.client = client
self.conversations = conversations
self.responseLength = responseLength
self.analyticsClient = analyticsClient
self.analyticsFeature = analyticsFeature
self.analyticsExecutionMode = analyticsExecutionMode
}
public static func configured(
store: any ConfigurationStore,
conversations: AIConversationStore,
taskKind: ManagedGatewayTaskKind = .aiQuestion,
thinkingEnabled: Bool = true
thinkingEnabled: Bool = true,
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
analyticsFeature: AnalyticsFeature = .aiAssistant
) throws -> AIQuestionService {
if store.credentialSource == .managed {
return AIQuestionService(
@@ -139,7 +150,10 @@ public struct AIQuestionService: Sendable {
grants: GatewayGrantCoordinator()
),
conversations: conversations,
responseLength: store.aiResponseLength
responseLength: store.aiResponseLength,
analyticsClient: analyticsClient,
analyticsFeature: analyticsFeature,
analyticsExecutionMode: .managed
)
}
// Same provider + baseURL + model resolution as dictation polish so the
@@ -172,7 +186,10 @@ public struct AIQuestionService: Sendable {
thinkingEnabled: thinkingEnabled
),
conversations: conversations,
responseLength: store.aiResponseLength
responseLength: store.aiResponseLength,
analyticsClient: analyticsClient,
analyticsFeature: analyticsFeature,
analyticsExecutionMode: .byok
)
}
@@ -199,28 +216,38 @@ public struct AIQuestionService: Sendable {
maxTokens: Self.outputTokenLimit
)
var accumulated = ""
for try await event in client.completeStreaming(
messages: messages,
timeout: Self.requestTimeout,
options: options
) {
try Task.checkCancellation()
switch event {
case .delta(let chunk):
accumulated += chunk
let preview = Self.streamingPreview(accumulated)
onPartial?(preview)
case .restart:
accumulated = ""
onPartial?("")
let operation = analyticsClient.startAIFeature(
analyticsFeature,
executionMode: analyticsExecutionMode
)
do {
var accumulated = ""
for try await event in client.completeStreaming(
messages: messages,
timeout: Self.requestTimeout,
options: options
) {
try Task.checkCancellation()
switch event {
case .delta(let chunk):
accumulated += chunk
let preview = Self.streamingPreview(accumulated)
onPartial?(preview)
case .restart:
accumulated = ""
onPartial?("")
}
}
}
try Task.checkCancellation()
try Task.checkCancellation()
let answer = Self.boundedAnswer(accumulated)
guard !answer.isEmpty else { throw ServiceError.emptyAnswer }
return answer
let answer = Self.boundedAnswer(accumulated)
guard !answer.isEmpty else { throw ServiceError.emptyAnswer }
operation.succeed()
return answer
} catch {
operation.fail(category: Self.analyticsFailureCategory(for: error))
throw error
}
}
/// Commit only after the host wins the utterance terminal claim. Keeping
@@ -254,6 +281,42 @@ public struct AIQuestionService: Sendable {
return String(prefix.dropLast()) + ""
}
private static func analyticsFailureCategory(
for error: Error
) -> AnalyticsFailureCategory {
if error is CancellationError {
return .cancelled
}
if error is ServiceError {
return .validation
}
if let error = error as? ManagedGatewayError {
switch error {
case .insufficientCredits:
return .insufficientCredits
case .timeout:
return .timeout
case .missingGrant, .scopeNotGranted, .invalidGrant:
return .validation
case .server:
return .provider
}
}
if let error = error as? LLMError {
switch error {
case .cancelled:
return .cancelled
case .transport, .rateLimited:
return .network
case .invalidURL, .noAPIKey, .decoding:
return .validation
case .http:
return .provider
}
}
return .unknown
}
/// Soft cap for live drafts no ellipsis mid-stream.
public static func streamingPreview(_ value: String) -> String {
if value.count <= AIQuestionLimits.maximumAnswerCharacterCount {
@@ -394,7 +394,13 @@ public struct AppGroupStore: @unchecked Sendable {
// MARK: - Client
public func makeClient(taskKind: ManagedGatewayTaskKind?) -> LLMClient {
configuration.makeClient(taskKind: taskKind)
public func makeClient(
taskKind: ManagedGatewayTaskKind?,
requestPurpose: ManagedGatewayRequestPurpose?
) -> LLMClient {
configuration.makeClient(
taskKind: taskKind,
requestPurpose: requestPurpose
)
}
}
@@ -11,6 +11,8 @@ public enum KeyboardSetupBridge {
private enum Key {
static let fullAccessReady = "keyboard.extension.fullAccessReady"
static let lastSeenAt = "keyboard.extension.lastSeenAt"
static let onboardingPracticeExpiresAt = "keyboard.onboarding.practiceExpiresAt"
static let lastVoiceInsertionAt = "keyboard.extension.lastVoiceInsertionAt"
}
/// True when the keyboard extension last appeared with Full Access enabled.
@@ -19,11 +21,72 @@ public enum KeyboardSetupBridge {
return AppGroup.defaults.bool(forKey: Key.fullAccessReady)
}
/// True after the extension has appeared at least once. Unlike
/// `isReadyForOnboardingSkip`, this also covers an appearance without Full
/// Access so the host can explain the missing setting precisely.
public static var hasAppeared: Bool {
guard AppGroup.isAvailable else { return false }
return AppGroup.defaults.double(forKey: Key.lastSeenAt) > 0
}
/// A short-lived exception that lets the real keyboard complete its first
/// voice insertion while the host still owns the onboarding screen.
public static var isOnboardingPracticeActive: Bool {
onboardingPracticeIsActive()
}
/// Wall clock of the most recent voice insertion issued by the extension.
/// The host compares this with the current practice start time, so an old
/// insertion can never complete a new onboarding run.
public static var lastVoiceInsertionAt: Date? {
guard AppGroup.isAvailable else { return nil }
let value = AppGroup.defaults.double(forKey: Key.lastVoiceInsertionAt)
return value > 0 ? Date(timeIntervalSince1970: value) : nil
}
public static func onboardingPracticeIsActive(
defaults: UserDefaults? = nil,
now: Date = Date()
) -> Bool {
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return false }
return store.double(forKey: Key.onboardingPracticeExpiresAt) > now.timeIntervalSince1970
}
public static func setOnboardingPracticeActive(
_ active: Bool,
duration: TimeInterval = 30 * 60,
defaults: UserDefaults? = nil,
now: Date = Date()
) {
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return }
if active {
store.set(
now.addingTimeInterval(duration).timeIntervalSince1970,
forKey: Key.onboardingPracticeExpiresAt
)
} else {
store.removeObject(forKey: Key.onboardingPracticeExpiresAt)
}
AppGroupConfigDarwin.postConfigChanged()
}
/// Called from the keyboard extension on each appearance.
public static func markExtensionAppearance(hasFullAccess: Bool) {
guard AppGroup.isAvailable else { return }
let defaults = AppGroup.defaults
defaults.set(Date().timeIntervalSince1970, forKey: Key.lastSeenAt)
defaults.set(hasFullAccess, forKey: Key.fullAccessReady)
AppGroupConfigDarwin.postConfigChanged()
}
/// Called only after a Flow transcript has been inserted into the host
/// field, not when recognition merely produced a result.
public static func markVoiceInsertion() {
guard AppGroup.isAvailable else { return }
AppGroup.defaults.set(
Date().timeIntervalSince1970,
forKey: Key.lastVoiceInsertionAt
)
AppGroupConfigDarwin.postConfigChanged()
}
}
@@ -106,6 +106,9 @@ public final class KeyboardState: ObservableObject {
@Published public var flowSessionActive: Bool = false
/// Unified mic color / tap / hint source for the keyboard extension.
@Published public var micVoiceAvailability: MicVoiceAvailability = .unavailable(.hostNotReady)
/// Short-lived host-owned practice mode. It unlocks real dictation before
/// onboarding completion, but only while the onboarding text field is live.
@Published public var isOnboardingPracticeActive: Bool = false
/// When true, the mic is intentionally disabled (e.g. cloud engine
/// selected but the provider-specific API key is missing).
@Published public var micDisabled: Bool = false
@@ -54,6 +54,7 @@ public actor PolishingService {
let systemPrompt: String?
let providerIdOverride: String?
let taskKind: ManagedGatewayTaskKind?
let requestPurpose: ManagedGatewayRequestPurpose?
let context: PolishContext?
}
@@ -79,6 +80,7 @@ public actor PolishingService {
private let store: any ConfigurationStore
private let timeout: TimeInterval
private let analyticsClient: any AnalyticsClient
/// Optional injected client (mostly for testing). When nil we build
/// one from `store.makeClient()` per call.
private let injectedClient: LLMClient?
@@ -91,11 +93,13 @@ public actor PolishingService {
public init(
store: any ConfigurationStore = AppGroupStore(),
client: LLMClient? = nil,
timeout: TimeInterval? = nil
timeout: TimeInterval? = nil,
analyticsClient: any AnalyticsClient = NoopAnalyticsClient()
) {
self.store = store
self.injectedClient = client
self.timeout = timeout ?? LLMClientFactory.defaultRequestTimeout
self.analyticsClient = analyticsClient
}
/// Context-aware polish entry point. The optional
@@ -110,6 +114,7 @@ public actor PolishingService {
systemPrompt: String? = nil,
providerIdOverride: String? = nil,
taskKind: ManagedGatewayTaskKind? = nil,
requestPurpose: ManagedGatewayRequestPurpose? = nil,
context: PolishContext? = nil
) async throws -> String {
try await performPolish(
@@ -119,6 +124,7 @@ public actor PolishingService {
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride,
taskKind: taskKind,
requestPurpose: requestPurpose,
context: context
)
).text
@@ -132,6 +138,7 @@ public actor PolishingService {
systemPrompt: String? = nil,
providerIdOverride: String? = nil,
taskKind: ManagedGatewayTaskKind? = nil,
requestPurpose: ManagedGatewayRequestPurpose? = nil,
context: PolishContext? = nil
) async throws -> PolishOutcome {
try await performPolish(
@@ -141,6 +148,7 @@ public actor PolishingService {
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride,
taskKind: taskKind,
requestPurpose: requestPurpose,
context: context
)
)
@@ -152,6 +160,7 @@ public actor PolishingService {
let systemPrompt = request.systemPrompt
let providerIdOverride = request.providerIdOverride
let taskKind = request.taskKind
let requestPurpose = request.requestPurpose
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw PolishError.noTranscript }
@@ -185,14 +194,26 @@ public actor PolishingService {
}
}
let remoteResult = try await polishRemote(
trimmed,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride,
taskKind: taskKind,
context: resolvedContext
let operation = analyticsClient.startAIFeature(
.polish,
executionMode: analyticsExecutionMode
)
let remoteResult: RemotePolishResult
do {
remoteResult = try await polishRemote(
trimmed,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride,
taskKind: taskKind,
requestPurpose: requestPurpose,
context: resolvedContext
)
operation.succeed()
} catch {
operation.fail(category: Self.analyticsFailureCategory(for: error))
throw error
}
// Translation and custom prompts bypass the polish post-processor.
if mode != .polish || (systemPrompt != nil && !(systemPrompt?.isEmpty ?? true)) {
@@ -214,6 +235,51 @@ public actor PolishingService {
return override
}
private var analyticsExecutionMode: AnalyticsExecutionMode {
store.credentialSource == .managed ? .managed : .byok
}
private static func analyticsFailureCategory(
for error: Error
) -> AnalyticsFailureCategory {
if error is CancellationError {
return .cancelled
}
if let error = error as? PolishError {
switch error {
case .timeout:
return .timeout
case .noTranscript, .missingAPIKey, .keychainLocked:
return .validation
}
}
if let error = error as? ManagedGatewayError {
switch error {
case .insufficientCredits:
return .insufficientCredits
case .timeout:
return .timeout
case .missingGrant, .scopeNotGranted, .invalidGrant:
return .validation
case .server:
return .provider
}
}
if let error = error as? LLMError {
switch error {
case .cancelled:
return .cancelled
case .transport, .rateLimited:
return .network
case .invalidURL, .noAPIKey, .decoding:
return .validation
case .http:
return .provider
}
}
return .unknown
}
static func managedGatewayTaskKind(for mode: PolishMode) -> ManagedGatewayTaskKind {
switch mode {
case .polish:
@@ -229,6 +295,7 @@ public actor PolishingService {
systemPrompt: String? = nil,
providerIdOverride: String? = nil,
taskKind: ManagedGatewayTaskKind? = nil,
requestPurpose: ManagedGatewayRequestPurpose? = nil,
context: PolishContext
) async throws -> RemotePolishResult {
let effectiveProviderId = Self.resolvedProviderId(
@@ -240,7 +307,8 @@ public actor PolishingService {
client = injectedClient
} else if store.credentialSource == .managed {
client = store.makeClient(
taskKind: taskKind ?? Self.managedGatewayTaskKind(for: mode)
taskKind: taskKind ?? Self.managedGatewayTaskKind(for: mode),
requestPurpose: requestPurpose
)
} else {
let preset = LLMProvider.provider(id: effectiveProviderId)