Files
XStats/Packages/XStatsKit/Sources/AIUsage/LocalUsageReport.swift
T
ysicing 3575a97de5 fix(ai-usage): 修正子代理回放判定并清理配额时代残留
修复子代理与 fork 会话的 Token 全量丢失:原判定要求 task_started 带
started_at 且不早于会话创建时间才结束回放,但子代理的 started_at 是父级
回合的开始时间,天然早于子会话文件,新版 Codex 更是完全不发该字段。改为
首个 task_started 无条件结束回放——实测 661 个真实 rollout 中没有任何一个
的 token_count 出现在它之前。本机语料 A/B:Token 13.60G → 14.53G(+6.8%),
用量记录 68997 → 73566。

其他修复:
- 两个 Provider 钉到专用串行执行器,冷扫描不再占用协作线程池。把池限制
  成一条线程实测,修复前并行的每秒采样在 43 秒扫描期间一次都没能推进。
- 按日期与模型聚合并定序输出,行数 701 → 272 且 id 唯一;顺带修掉冷扫描
  与检查点恢复顺序不一致导致每次刷新都产生"不相等"报告的问题。
- 缓存行加剪枝,覆盖归档搬走的 rollout、删除的项目目录与旧解析命名空间;
  检查点仅在末行未完时存第二份状态,Claude 事件按保留窗口裁剪。
- 复用 ISO8601 解析器,不再逐行创建。
- 数字与日期改跟随应用语言,弹窗与菜单栏共用同一套 Token 缩写。
- 窄弹窗热力图按设计尺寸铺开后横向滚动,此前被钉在面板宽度上永不溢出。
- 打开菜单栏项一并开启扫描,并为无数据状态补上悬停文案。
- 删除未被应用构造的配额 Provider、凭据读取与响应映射(约 330 行)及其
  全链路管线,并清掉 37 条孤立译文 × 8 个语言文件。
2026-09-23 08:50:41 +08:00

79 lines
3.8 KiB
Swift

// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
public struct ModelTokenUsage: Equatable, Sendable, Identifiable, Codable {
public let day: Date
public let model: String
public var input: Int = 0
public var cached: Int = 0
public var cacheCreated: Int = 0
public var output: Int = 0
public var records: Int = 0
public var total: Int { input + output }
public var id: String { "\(day.timeIntervalSince1970):\(model)" }
public init(day: Date, model: String, input: Int = 0, cached: Int = 0, output: Int = 0, records: Int = 0, cacheCreated: Int = 0) {
self.day = day; self.model = model; self.input = input
self.cached = cached; self.output = output; self.records = records
self.cacheCreated = cacheCreated
}
public mutating func add(_ other: Self) {
input += other.input; cached += other.cached; output += other.output; records += other.records
cacheCreated += other.cacheCreated
}
}
public struct LocalUsageReport: Equatable, Sendable {
public let rows: [ModelTokenUsage]
public let fileCount: Int
public let unreadableFiles: Int
public init(rows: [ModelTokenUsage], fileCount: Int, unreadableFiles: Int = 0) {
self.rows = rows; self.fileCount = fileCount; self.unreadableFiles = unreadableFiles
}
public func selected(days: Int, model: String? = nil, now: Date = Date(), calendar: Calendar = .current) -> [ModelTokenUsage] {
let today = calendar.startOfDay(for: now)
let start = calendar.date(byAdding: .day, value: -(max(1, days) - 1), to: today) ?? today
return rows.filter { $0.day >= start && $0.day <= now && (model == nil || $0.model == model) }
}
public static func total(_ rows: [ModelTokenUsage]) -> ModelTokenUsage {
rows.reduce(into: ModelTokenUsage(day: .distantPast, model: "")) { $0.add($1) }
}
/// 活动图模式同时控制摘要:每日取本日,每周取本周,累计取本月;热力图仍单独取全年。
public func summary(mode: UsageActivityMode, model: String? = nil, now: Date = Date(),
calendar: Calendar = .current) -> [ModelTokenUsage] {
let component: Calendar.Component = mode == .daily ? .day : mode == .weekly ? .weekOfYear : .month
let start = calendar.dateInterval(of: component, for: now)?.start ?? calendar.startOfDay(for: now)
return rows.filter { $0.day >= start && $0.day <= now && (model == nil || $0.model == model) }
}
/// 同一天同一模型会来自多个会话文件,而 `ModelTokenUsage.id` 正以“日期 + 模型”为键,
/// 所以必须先合并再交给视图,否则 `rows` 里会出现大量重复 id。
public static func aggregated(_ rows: some Sequence<ModelTokenUsage>) -> [ModelTokenUsage] {
var merged: [String: ModelTokenUsage] = [:]
for row in rows {
var value = merged[row.id] ?? ModelTokenUsage(day: row.day, model: row.model)
value.add(row)
merged[row.id] = value
}
// 必须定序:解析状态存在字典里,冷扫描和从检查点恢复的迭代顺序不同,
// 顺序不定会让每次刷新都产生一个“不相等”的报告,白白触发重绘并清掉悬停提示。
return merged.values.sorted { $0.day == $1.day ? $0.model < $1.model : $0.day < $1.day }
}
public static func models(_ rows: [ModelTokenUsage]) -> [ModelTokenUsage] {
var result: [String: ModelTokenUsage] = [:]
for row in rows {
var value = result[row.model] ?? ModelTokenUsage(day: .distantPast, model: row.model)
value.add(row); result[row.model] = value
}
return result.values.sorted { $0.total == $1.total ? $0.model < $1.model : $0.total > $1.total }
}
}