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 {