feat(home): add usage statistics card above preview field

Track dictation duration, dictation/translation character counts, and
personal dictionary size. Record metrics when Flow finalizes utterances.

Co-authored-by: Rocky <hkgood@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-07-03 09:54:13 +00:00
parent 5cb772d699
commit b909e9082b
7 changed files with 288 additions and 2 deletions
+27 -2
View File
@@ -57,6 +57,8 @@ final class FlowSessionManager: ObservableObject {
private var currentPartial = ""
private var lastFinal = ""
private var chunkWarnings: [String] = []
/// Wall-clock span of the current mic-open utterance (excludes LLM polish).
private var utteranceRecordingStartedAt: Date?
/// True while the host app scene is `.active` drives foreground renewal.
private var isAppForeground = false
private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid
@@ -407,6 +409,7 @@ final class FlowSessionManager: ObservableObject {
chunkedPipeline = pipeline
isUtteranceRecording = true
utteranceRecordingStartedAt = Date()
FlowDiagnostics.log(
"beginUtterance engine=\(store.engineMode) asr=\(store.localASRBackend.rawValue) " +
"asrType=\(type(of: asr)) pipelined=true max=\(Int(FlowSessionKeys.maxUtteranceDuration))s"
@@ -473,6 +476,7 @@ final class FlowSessionManager: ObservableObject {
private func abortUtterance() {
isUtteranceRecording = false
isUtteranceProcessing = false
utteranceRecordingStartedAt = nil
finalizeTask?.cancel()
finalizeTask = nil
asrTask?.cancel()
@@ -490,6 +494,7 @@ final class FlowSessionManager: ObservableObject {
private func failUtterance(message: String) {
isUtteranceRecording = false
isUtteranceProcessing = false
utteranceRecordingStartedAt = nil
finalizeTask?.cancel()
finalizeTask = nil
asrTask?.cancel()
@@ -507,6 +512,7 @@ final class FlowSessionManager: ObservableObject {
private func finishProcessing(withError message: String) {
isUtteranceProcessing = false
utteranceRecordingStartedAt = nil
finalizeTask?.cancel()
finalizeTask = nil
chunkedPipeline = nil
@@ -555,12 +561,15 @@ final class FlowSessionManager: ObservableObject {
? "flow.error.recognitionInterrupted"
: "flow.error.noSpeech"
FlowDiagnostics.log("finalize failed: empty transcript after \(String(format: "%.1f", asrElapsed))s")
utteranceRecordingStartedAt = nil
FlowSessionBridge.storeTranscriptionError(
AppL10n.string(key)
)
return
}
let recordingDuration = consumeRecordingDuration()
let engineMode = store.engineMode
let chunkNote = Self.chunkWarningMessage(chunkWarnings)
// Re-read App Group at finalize so chip-side translation changes
@@ -574,7 +583,12 @@ final class FlowSessionManager: ObservableObject {
"finalize ASR-only total=\(String(format: "%.1f", Date().timeIntervalSince(pipelineStarted)))s " +
"len=\(text.count)"
)
SpeechHistoryStore.shared.append(text: text, engineMode: engineMode)
SpeechHistoryStore.shared.recordUtterance(
text: text,
engineMode: engineMode,
duration: recordingDuration,
wasTranslation: false
)
currentPartial = ""
lastFinal = ""
chunkWarnings = []
@@ -616,7 +630,12 @@ final class FlowSessionManager: ObservableObject {
FlowSessionBridge.storeTranscriptionResult(text, polishWarning: warning)
}
SpeechHistoryStore.shared.append(text: delivered, engineMode: engineMode)
SpeechHistoryStore.shared.recordUtterance(
text: delivered,
engineMode: engineMode,
duration: recordingDuration,
wasTranslation: pipelineStore.isTranslationEffective
)
currentPartial = ""
lastFinal = ""
@@ -639,6 +658,12 @@ final class FlowSessionManager: ObservableObject {
return warnings.joined(separator: "\n")
}
private func consumeRecordingDuration() -> TimeInterval {
defer { utteranceRecordingStartedAt = nil }
guard let start = utteranceRecordingStartedAt else { return 0 }
return max(0, Date().timeIntervalSince(start))
}
/// v0.2.0: surface the local-mode cloud-polish error path with a
/// localised hint ("please fill in your DeepSeek key in Settings")
/// rather than letting the keyboard show a generic network error.