feat(keyboard): ship AI mode surface with streaming search answers

Add the AI keyboard tab, Agent settings, and user-owned LLM key path for 1.7.0, including streaming answers and web-search transports without the built-in DeepSeek fallback.
This commit is contained in:
Rocky
2026-08-11 01:06:27 +08:00
parent f6212dcd2c
commit 3de665d254
81 changed files with 4467 additions and 398 deletions
@@ -14,6 +14,10 @@ public final class UsageStatisticsStore: ObservableObject {
@Published public private(set) var dictationDurationSeconds: TimeInterval = 0
@Published public private(set) var dictationCharacterCount: Int = 0
@Published public private(set) var translationCharacterCount: Int = 0
@Published public private(set) var aiCharacterCount: Int = 0
public var totalInputCharacterCount: Int {
dictationCharacterCount + translationCharacterCount + aiCharacterCount
}
/// Cross-device dictation characters per local day (`yyyy-MM-dd`), used by
/// the home page's 7-day chart.
@Published public private(set) var dailyDictationCharacters: [String: Int] = [:]
@@ -75,6 +79,35 @@ public final class UsageStatisticsStore: ObservableObject {
}
}
/// Record an explicitly inserted AI answer exactly once. The commit id and
/// counter update share one device-slice write, so outbox retries are safe.
public func recordAIInsertion(text: String, commitID: UUID) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let deviceID = SyncDeviceID.current(defaults: defaults)
var slice = SyncedUsageStatisticsStorage.currentDeviceSlice(
from: defaults,
deviceID: deviceID
)
guard !slice.appliedAICommitIDs.contains(commitID) else { return }
slice.aiCharacterCount += Self.characterCount(for: trimmed)
slice.appliedAICommitIDs.append(commitID)
slice.appliedAICommitIDs = Array(slice.appliedAICommitIDs.suffix(128))
slice.updatedAt = Date()
SyncedUsageStatisticsStorage.upsertCurrentDeviceSlice(
slice,
defaults: defaults,
deviceID: deviceID
)
reloadFromDisk()
Task {
try? await UsageStatisticsCloudSync.shared.pushLocalIfEnabled()
}
}
/// Refreshes the published totals from disk. Display-only: it reads the
/// aggregated cross-device sum and NEVER writes it back (writing would
/// corrupt the per-device slices see `recordUtterance`).
@@ -84,6 +117,7 @@ public final class UsageStatisticsStore: ObservableObject {
dictationDurationSeconds = aggregated.dictationDurationSeconds
dictationCharacterCount = aggregated.dictationCharacterCount
translationCharacterCount = aggregated.translationCharacterCount
aiCharacterCount = aggregated.aiCharacterCount
dailyDictationCharacters = payload.aggregatedDailyDictationCharacters
}