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