feat(app): add resilient Flow recovery and privacy-safe analytics
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user