import Foundation public struct UsageSummaryAccumulator { private let now: Date private var calendar: Calendar private let today: Date private let month: DateComponents private let trendWindows: [TrendMode: TrendWindow] private var providerTotals: [Provider: TokenBucket] private var todayProviderTotals: [Provider: TokenBucket] private var dailyBuckets: [Date: [Provider: Int]] = [:] private var dailyDetails: [Date: DailyUsageDetail] = [:] private var trendBuckets: [TrendMode: [Date: [Provider: Int]]] = [:] private var todayTokens = 0 private var monthTokens = 0 private var totalTokens = 0 private var cachedTokens = 0 private var sessions = Set() public init( now: Date = Date(), calendar baseCalendar: Calendar = Calendar.current ) { self.now = now var calendar = baseCalendar calendar.firstWeekday = 2 self.calendar = calendar today = calendar.startOfDay(for: now) month = calendar.dateComponents([.year, .month], from: now) providerTotals = Dictionary(uniqueKeysWithValues: Provider.allCases.map { ($0, TokenBucket(provider: $0)) }) todayProviderTotals = Dictionary(uniqueKeysWithValues: Provider.allCases.map { ($0, TokenBucket(provider: $0)) }) trendWindows = Dictionary(uniqueKeysWithValues: TrendMode.allCases.map { mode in let reference = mode.periodStart(for: now, calendar: calendar) return (mode, TrendWindow( reference: reference, start: mode.startDate(reference: reference, calendar: calendar), component: mode.calendarComponent )) }) } public mutating func add(_ record: UsageRecord) { add( provider: record.provider, sourcePath: record.sourcePath, timestamp: record.timestamp, sessionID: record.sessionID, inputTokens: record.inputTokens, outputTokens: record.outputTokens, cachedTokens: record.cachedTokens, reasoningTokens: record.reasoningTokens, toolTokens: record.toolTokens, totalTokens: record.totalTokens ) } public mutating func add( provider: Provider, sourcePath: String, timestamp: Date, sessionID: String, inputTokens: Int, outputTokens: Int, cachedTokens: Int, reasoningTokens: Int, toolTokens: Int, totalTokens: Int ) { self.totalTokens += totalTokens self.cachedTokens += cachedTokens sessions.insert(sessionID.isEmpty ? sourcePath : sessionID) providerTotals[provider, default: TokenBucket(provider: provider)].add( inputTokens: inputTokens, outputTokens: outputTokens, cachedTokens: cachedTokens, reasoningTokens: reasoningTokens, toolTokens: toolTokens, totalTokens: totalTokens ) let recordDay = calendar.startOfDay(for: timestamp) dailyBuckets[recordDay, default: Provider.zeroTokenMap][provider, default: 0] += totalTokens if dailyDetails[recordDay] == nil { dailyDetails[recordDay] = DailyUsageDetail(date: recordDay) } dailyDetails[recordDay]?.add( provider: provider, inputTokens: inputTokens, outputTokens: outputTokens, cachedTokens: cachedTokens, totalTokens: totalTokens ) if recordDay == today { todayTokens += totalTokens todayProviderTotals[provider, default: TokenBucket(provider: provider)].add( inputTokens: inputTokens, outputTokens: outputTokens, cachedTokens: cachedTokens, reasoningTokens: reasoningTokens, toolTokens: toolTokens, totalTokens: totalTokens ) } let recordMonth = calendar.dateComponents([.year, .month], from: timestamp) if recordMonth.year == month.year && recordMonth.month == month.month { monthTokens += totalTokens } for mode in TrendMode.allCases { guard let window = trendWindows[mode] else { continue } let period = mode.periodStart(for: timestamp, calendar: calendar) guard period >= window.start && period <= window.reference else { continue } trendBuckets[mode, default: [:]][period, default: Provider.zeroTokenMap][provider, default: 0] += totalTokens } } public func makeSummary() -> UsageSummary { let dailyTrend = dailyBuckets .sorted { $0.key < $1.key } .suffix(30) .map { DailyPoint(date: $0.key, values: $0.value) } let sortedDailyDetails = dailyDetails.values.sorted { $0.date > $1.date } let trendPointsByMode = Dictionary(uniqueKeysWithValues: TrendMode.allCases.map { mode in (mode, makeTrendPoints(mode: mode)) }) let toolRows = Provider.allCases.compactMap { provider -> ToolRow? in guard let bucket = providerTotals[provider], bucket.totalTokens > 0 else { return nil } let trendTokens = dailyTrend.map { $0.values[provider, default: 0] } return ToolRow( provider: provider, inputTokens: bucket.inputTokens, outputTokens: bucket.outputTokens, cachedTokens: bucket.cachedTokens, totalTokens: bucket.totalTokens, trendTokens: trendTokens ) } .sorted { $0.totalTokens > $1.totalTokens } let cacheHitRate = totalTokens > 0 ? Int((Double(cachedTokens) / Double(totalTokens) * 100).rounded()) : 0 let cards = SummaryCards( todayTokens: todayTokens, monthTokens: monthTokens, totalTokens: totalTokens, cacheHitRate: cacheHitRate, sessionCount: sessions.count ) return UsageSummary( generatedAt: now, records: [], cards: cards, providerTotals: providerTotals, todayProviderTotals: todayProviderTotals, dailyTrend: Array(dailyTrend), trendPointsByMode: trendPointsByMode, dailyDetails: sortedDailyDetails, toolRows: toolRows, insights: UsageSummary.makeInsights( providerTotals: providerTotals, todayTokens: todayTokens, cacheHitRate: cacheHitRate ) ) } private func makeTrendPoints(mode: TrendMode) -> [DailyPoint] { guard let window = trendWindows[mode] else { return [] } let buckets = trendBuckets[mode, default: [:]] var points: [DailyPoint] = [] var cursor = window.start while cursor <= window.reference { points.append(DailyPoint(date: cursor, values: buckets[cursor, default: Provider.zeroTokenMap])) guard let next = calendar.date(byAdding: window.component, value: 1, to: cursor) else { break } cursor = next } return points } } private struct TrendWindow { let reference: Date let start: Date let component: Calendar.Component } public extension UsageSummary { static func make( records: [UsageRecord], now: Date = Date(), calendar baseCalendar: Calendar = Calendar.current ) -> UsageSummary { var accumulator = UsageSummaryAccumulator(now: now, calendar: baseCalendar) for record in records { accumulator.add(record) } return accumulator.makeSummary() } static func makeInsights( providerTotals: [Provider: TokenBucket], todayTokens: Int, cacheHitRate: Int ) -> [Insight] { let top = providerTotals.values .filter { $0.totalTokens > 0 } .sorted { $0.totalTokens > $1.totalTokens } .first var insights: [Insight] = [] if let top, let provider = top.provider { insights.append(Insight( tone: .warning, title: "\(provider.rawValue) 是当前主要用量来源", message: "已从本地日志统计 \(TokenFormatter.format(top.totalTokens)) Tokens。" )) } insights.append(Insight( tone: .positive, title: "检测到缓存 Tokens", message: "当前 \(cacheHitRate)% 的统计用量来自缓存上下文。" )) insights.append(Insight( tone: .accent, title: "今日用量快照已更新", message: "当前本地日期已统计 \(TokenFormatter.format(todayTokens)) Tokens。" )) return insights } func trendPoints( mode: TrendMode, calendar baseCalendar: Calendar = Calendar.current ) -> [DailyPoint] { if let points = trendPointsByMode[mode] { return points } return [] } func dailyUsageDetails(calendar baseCalendar: Calendar = Calendar.current) -> [DailyUsageDetail] { dailyDetails } func updatingGeneratedAt(_ date: Date) -> UsageSummary { UsageSummary( generatedAt: date, records: records, cards: cards, providerTotals: providerTotals, todayProviderTotals: todayProviderTotals, dailyTrend: dailyTrend, trendPointsByMode: trendPointsByMode, dailyDetails: dailyDetails, toolRows: toolRows, insights: insights ) } } extension Provider { static var zeroTokenMap: [Provider: Int] { Dictionary(uniqueKeysWithValues: Provider.allCases.map { ($0, 0) }) } } extension TrendMode { var calendarComponent: Calendar.Component { switch self { case .daily: return .hour case .weekly: return .day case .monthly: return .day case .yearly: return .month } } var periodColumn: String { switch self { case .daily: return "hour_start" case .weekly, .monthly: return "day_start" case .yearly: return "month_start" } } func periodStart(for date: Date, calendar: Calendar) -> Date { switch self { case .daily: let components = calendar.dateComponents([.year, .month, .day, .hour], from: date) return calendar.date(from: components) ?? calendar.startOfDay(for: date) case .weekly: return calendar.startOfDay(for: date) case .monthly: return calendar.startOfDay(for: date) case .yearly: let components = calendar.dateComponents([.year, .month], from: date) return calendar.date(from: components) ?? calendar.startOfDay(for: date) } } func startDate(reference: Date, calendar: Calendar) -> Date { switch self { case .daily: return calendar.startOfDay(for: reference) case .weekly: return calendar.dateInterval(of: .weekOfYear, for: reference)?.start ?? calendar.startOfDay(for: reference) case .monthly: let components = calendar.dateComponents([.year, .month], from: reference) return calendar.date(from: components) ?? calendar.startOfDay(for: reference) case .yearly: let components = calendar.dateComponents([.year], from: reference) return calendar.date(from: components) ?? calendar.startOfDay(for: reference) } } }