feat(ai-usage): 添加显示偏好与费用估算
This commit is contained in:
1 parent
7b789b1ec2
commit
e33fa55154
32 files changed
+1320
-63
No files matched your search
@@ -0,0 +1,148 @@
|
||||
// Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Foundation
|
||||
import CoreFoundation
|
||||
|
||||
/// 公开 API 的基础美元单价,单位为每百万 Token;不是订阅账单。
|
||||
public struct AIModelPrice: Codable, Equatable, Sendable {
|
||||
public let input: Double
|
||||
public let output: Double
|
||||
public let cacheRead: Double?
|
||||
public let cacheWrite: Double?
|
||||
|
||||
public init(input: Double, output: Double, cacheRead: Double? = nil, cacheWrite: Double? = nil) {
|
||||
self.input = input; self.output = output
|
||||
self.cacheRead = cacheRead; self.cacheWrite = cacheWrite
|
||||
}
|
||||
|
||||
var isValid: Bool {
|
||||
[input, output].allSatisfy { $0.isFinite && $0 >= 0 }
|
||||
&& [cacheRead, cacheWrite].compactMap { $0 }.allSatisfy { $0.isFinite && $0 >= 0 }
|
||||
}
|
||||
}
|
||||
|
||||
public struct AICostEstimate: Equatable, Sendable {
|
||||
public let usd: Double?
|
||||
public let unpricedModels: [String]
|
||||
|
||||
public init(usd: Double?, unpricedModels: [String]) {
|
||||
self.usd = usd; self.unpricedModels = unpricedModels
|
||||
}
|
||||
}
|
||||
|
||||
public struct AIPriceCatalog: Codable, Equatable, Sendable {
|
||||
public let prices: [String: AIModelPrice]
|
||||
public let fetchedAt: Date
|
||||
|
||||
public init(prices: [String: AIModelPrice], fetchedAt: Date) {
|
||||
self.prices = prices; self.fetchedAt = fetchedAt
|
||||
}
|
||||
|
||||
/// 本地输入包含缓存读取和创建;输出已包含推理 Token,不能再次叠加。
|
||||
/// 仅采用目录中的基础费率,不推测逐请求上下文阶梯、缓存时长或订阅折扣。
|
||||
public func estimate(_ rows: [ModelTokenUsage]) -> AICostEstimate {
|
||||
var total = 0.0
|
||||
var priced = rows.isEmpty
|
||||
var unknown = Set<String>()
|
||||
for row in rows {
|
||||
guard row.input >= 0, row.cached >= 0, row.cacheCreated >= 0, row.output >= 0,
|
||||
row.cached <= row.input, row.cacheCreated <= row.input - row.cached else {
|
||||
unknown.insert(row.model); continue
|
||||
}
|
||||
if row.input == 0 && row.output == 0 { priced = true; continue }
|
||||
var model = row.model
|
||||
// 只去掉明确的厂商命名空间;不剥离日期、版本或任意后缀。
|
||||
if model.hasPrefix("openai/"), !model.dropFirst(7).hasPrefix("claude-") {
|
||||
model = String(model.dropFirst(7))
|
||||
} else if model.hasPrefix("anthropic/"), model.dropFirst(10).hasPrefix("claude-") {
|
||||
model = String(model.dropFirst(10))
|
||||
}
|
||||
guard let price = prices[row.model] ?? prices[model], price.isValid else {
|
||||
unknown.insert(row.model); continue
|
||||
}
|
||||
let categories: [(Int, Double?)] = [
|
||||
(row.input - row.cached - row.cacheCreated, price.input),
|
||||
(row.cached, price.cacheRead), (row.cacheCreated, price.cacheWrite), (row.output, price.output)
|
||||
]
|
||||
var amount = 0.0
|
||||
var complete = true
|
||||
for (tokens, rate) in categories where tokens > 0 {
|
||||
guard let rate else { complete = false; break }
|
||||
// 先缩放,避免合法大 Token 数与单价的中间乘积溢出。
|
||||
amount += (Double(tokens) / 1_000_000) * rate
|
||||
}
|
||||
guard complete, amount.isFinite, (total + amount).isFinite else {
|
||||
unknown.insert(row.model); continue
|
||||
}
|
||||
total += amount; priced = true
|
||||
}
|
||||
return AICostEstimate(usd: priced ? total : nil, unpricedModels: unknown.sorted())
|
||||
}
|
||||
|
||||
/// models.dev 的 cost 为美元/百万 Token。忽略第三方转售商和缺失必需单价的模型。
|
||||
static func parse(_ data: Data, fetchedAt: Date) throws -> Self {
|
||||
guard data.count <= AICostReferenceStore.catalogLimit,
|
||||
let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw AICostReferenceError.invalidResponse
|
||||
}
|
||||
var prices: [String: AIModelPrice] = [:]
|
||||
for provider in ["openai", "anthropic"] {
|
||||
guard let entry = root[provider] as? [String: Any],
|
||||
let models = entry["models"] as? [String: Any] else { continue }
|
||||
for (model, value) in models {
|
||||
try Task.checkCancellation()
|
||||
guard !model.isEmpty, model.utf8.count <= 256, !model.contains("/"),
|
||||
let object = value as? [String: Any],
|
||||
let cost = object["cost"] as? [String: Any],
|
||||
let input = AICostReferenceNumber.nonnegative(cost["input"]),
|
||||
let output = AICostReferenceNumber.nonnegative(cost["output"]) else { continue }
|
||||
// 非法可选价格作为缺失处理,只有实际使用该类别时才无法估算。
|
||||
prices[model] = AIModelPrice(input: input, output: output,
|
||||
cacheRead: AICostReferenceNumber.nonnegative(cost["cache_read"]),
|
||||
cacheWrite: AICostReferenceNumber.nonnegative(cost["cache_write"]))
|
||||
}
|
||||
}
|
||||
guard !prices.isEmpty else { throw AICostReferenceError.invalidResponse }
|
||||
return Self(prices: prices, fetchedAt: fetchedAt)
|
||||
}
|
||||
}
|
||||
|
||||
public struct AIExchangeRate: Codable, Equatable, Sendable {
|
||||
public let cnyPerUSD: Double
|
||||
public let fetchedAt: Date
|
||||
|
||||
public init(cnyPerUSD: Double, fetchedAt: Date) {
|
||||
self.cnyPerUSD = cnyPerUSD; self.fetchedAt = fetchedAt
|
||||
}
|
||||
|
||||
static func parse(_ data: Data, fetchedAt: Date) throws -> Self {
|
||||
guard data.count <= AICostReferenceStore.exchangeLimit,
|
||||
let root = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
root["result"] as? String == "success", root["base_code"] as? String == "USD",
|
||||
let rates = root["rates"] as? [String: Any],
|
||||
let rate = AICostReferenceNumber.nonnegative(rates["CNY"]), rate > 0 else {
|
||||
throw AICostReferenceError.invalidResponse
|
||||
}
|
||||
return Self(cnyPerUSD: rate, fetchedAt: fetchedAt)
|
||||
}
|
||||
}
|
||||
|
||||
public struct AICostReferenceSnapshot: Equatable, Sendable {
|
||||
public let catalog: AIPriceCatalog?
|
||||
public let exchangeRate: AIExchangeRate?
|
||||
|
||||
public init(catalog: AIPriceCatalog?, exchangeRate: AIExchangeRate?) {
|
||||
self.catalog = catalog; self.exchangeRate = exchangeRate
|
||||
}
|
||||
}
|
||||
|
||||
enum AICostReferenceError: Error, Equatable { case invalidResponse, responseTooLarge }
|
||||
|
||||
private enum AICostReferenceNumber {
|
||||
static func nonnegative(_ value: Any?) -> Double? {
|
||||
guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID(),
|
||||
number.doubleValue.isFinite, number.doubleValue >= 0 else { return nil }
|
||||
return number.doubleValue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 按需获取公开参考数据,无定时器;磁盘仅保存两个有界的参考数据文件。
|
||||
public actor AICostReferenceStore {
|
||||
public static let shared = AICostReferenceStore()
|
||||
public static var defaultCacheDirectory: URL {
|
||||
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("XStats/AIReference", isDirectory: true)
|
||||
}
|
||||
static let catalogLimit = 16 * 1024 * 1024
|
||||
static let exchangeLimit = 1024 * 1024
|
||||
private static let cacheLimit = 1024 * 1024
|
||||
private static let retryDelay: TimeInterval = 600
|
||||
private let cacheDirectory: URL?
|
||||
private let now: @Sendable () -> Date
|
||||
private let fetch: @Sendable (URL, Int) async throws -> Data
|
||||
private var loaded = false
|
||||
private var catalog: AIPriceCatalog?
|
||||
private var exchangeRate: AIExchangeRate?
|
||||
private var catalogRetryAt: Date?
|
||||
private var exchangeRetryAt: Date?
|
||||
private var flight: Flight?
|
||||
|
||||
private struct Flight {
|
||||
let id: UUID
|
||||
let task: Task<RefreshResult, Never>
|
||||
var waiters: Set<UUID>
|
||||
}
|
||||
private struct RefreshResult: Sendable {
|
||||
var catalog: AIPriceCatalog?
|
||||
var exchangeRate: AIExchangeRate?
|
||||
var catalogFailed = false
|
||||
var exchangeFailed = false
|
||||
}
|
||||
|
||||
/// nil 缓存目录用于无持久化环境。fetch/now 只用于注入可重复测试依赖。
|
||||
public init(cacheDirectory: URL? = AICostReferenceStore.defaultCacheDirectory,
|
||||
now: @escaping @Sendable () -> Date = { Date() },
|
||||
fetch: (@Sendable (URL, Int) async throws -> Data)? = nil) {
|
||||
self.cacheDirectory = cacheDirectory; self.now = now
|
||||
self.fetch = fetch ?? Self.download
|
||||
}
|
||||
|
||||
/// 失败继续返回已有缓存;并发需求合并,最后一个等待者取消时终止请求。
|
||||
/// 只有未取消的等待者可提交结果;美元需求不会主动请求汇率。
|
||||
public func refresh(needsExchangeRate: Bool) async -> AICostReferenceSnapshot {
|
||||
guard !Task.isCancelled else { return snapshot }
|
||||
loadCache()
|
||||
let waiter = UUID()
|
||||
while !Task.isCancelled {
|
||||
let date = now()
|
||||
let needsCatalog = needsRefresh(catalog?.fetchedAt, ttl: 86_400, retryAt: catalogRetryAt, now: date)
|
||||
let needsExchange = needsExchangeRate
|
||||
&& needsRefresh(exchangeRate?.fetchedAt, ttl: 43_200, retryAt: exchangeRetryAt, now: date)
|
||||
guard needsCatalog || needsExchange else { return snapshot }
|
||||
let current: Flight
|
||||
if var existing = flight {
|
||||
existing.waiters.insert(waiter); flight = existing; current = existing
|
||||
} else {
|
||||
let task = Task { await self.fetchReferences(catalog: needsCatalog, exchange: needsExchange) }
|
||||
current = Flight(id: UUID(), task: task, waiters: [waiter]); flight = current
|
||||
}
|
||||
let result = await withTaskCancellationHandler {
|
||||
await current.task.value
|
||||
} onCancel: {
|
||||
Task { await self.cancelWaiter(waiter, flightID: current.id) }
|
||||
}
|
||||
guard !Task.isCancelled else {
|
||||
cancelWaiter(waiter, flightID: current.id)
|
||||
return snapshot
|
||||
}
|
||||
// 其他等待者可能已经提交结果或取消了整轮;旧轮次不能覆盖新状态。
|
||||
if flight?.id == current.id {
|
||||
flight = nil
|
||||
guard !current.task.isCancelled else { continue }
|
||||
let finished = now()
|
||||
if let value = result.catalog {
|
||||
catalog = value; catalogRetryAt = nil; save(value, name: "prices-v1.json")
|
||||
} else if result.catalogFailed {
|
||||
catalogRetryAt = finished.addingTimeInterval(Self.retryDelay)
|
||||
}
|
||||
if let value = result.exchangeRate {
|
||||
exchangeRate = value; exchangeRetryAt = nil; save(value, name: "exchange-v1.json")
|
||||
} else if result.exchangeFailed {
|
||||
exchangeRetryAt = finished.addingTimeInterval(Self.retryDelay)
|
||||
}
|
||||
}
|
||||
// 若人民币调用加入了只获取价格的轮次,价格提交后再按需补一次汇率。
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private var snapshot: AICostReferenceSnapshot {
|
||||
AICostReferenceSnapshot(catalog: catalog, exchangeRate: exchangeRate)
|
||||
}
|
||||
|
||||
private func cancelWaiter(_ waiter: UUID, flightID: UUID) {
|
||||
guard var current = flight, current.id == flightID else { return }
|
||||
current.waiters.remove(waiter)
|
||||
if current.waiters.isEmpty { current.task.cancel(); flight = nil } else { flight = current }
|
||||
}
|
||||
|
||||
private func needsRefresh(_ fetchedAt: Date?, ttl: TimeInterval, retryAt: Date?, now: Date) -> Bool {
|
||||
if let retryAt, now < retryAt { return false }
|
||||
guard let fetchedAt else { return true }
|
||||
let age = now.timeIntervalSince(fetchedAt)
|
||||
return age < 0 || age >= ttl
|
||||
}
|
||||
|
||||
private func fetchReferences(catalog needsCatalog: Bool, exchange needsExchange: Bool) async -> RefreshResult {
|
||||
var result = RefreshResult()
|
||||
if needsCatalog {
|
||||
do {
|
||||
try Task.checkCancellation()
|
||||
let data = try await fetch(URL(string: "https://models.dev/api.json")!, Self.catalogLimit)
|
||||
try Task.checkCancellation()
|
||||
result.catalog = try AIPriceCatalog.parse(data, fetchedAt: now())
|
||||
} catch {
|
||||
result.catalogFailed = !Task.isCancelled
|
||||
}
|
||||
}
|
||||
if needsExchange && !Task.isCancelled {
|
||||
do {
|
||||
let data = try await fetch(URL(string: "https://open.er-api.com/v6/latest/USD")!, Self.exchangeLimit)
|
||||
try Task.checkCancellation()
|
||||
result.exchangeRate = try AIExchangeRate.parse(data, fetchedAt: now())
|
||||
} catch {
|
||||
result.exchangeFailed = !Task.isCancelled
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func loadCache() {
|
||||
guard !loaded else { return }
|
||||
loaded = true
|
||||
if let value: AIPriceCatalog = read(name: "prices-v1.json"),
|
||||
value.fetchedAt.timeIntervalSince1970.isFinite, !value.prices.isEmpty,
|
||||
value.prices.allSatisfy({ !$0.key.isEmpty && $0.key.utf8.count <= 256 && $0.value.isValid }) {
|
||||
catalog = value
|
||||
}
|
||||
if let value: AIExchangeRate = read(name: "exchange-v1.json"),
|
||||
value.fetchedAt.timeIntervalSince1970.isFinite, value.cnyPerUSD.isFinite, value.cnyPerUSD > 0 {
|
||||
exchangeRate = value
|
||||
}
|
||||
}
|
||||
|
||||
private func read<Value: Decodable>(name: String) -> Value? {
|
||||
guard let url = cacheDirectory?.appendingPathComponent(name),
|
||||
let file = try? FileHandle(forReadingFrom: url) else { return nil }
|
||||
defer { try? file.close() }
|
||||
// 有界读取也防止磁盘缓存被意外替换为大型文件。
|
||||
guard let data = try? file.read(upToCount: Self.cacheLimit + 1), data.count <= Self.cacheLimit else { return nil }
|
||||
return try? JSONDecoder().decode(Value.self, from: data)
|
||||
}
|
||||
|
||||
private func save(_ value: some Encodable, name: String) {
|
||||
guard !Task.isCancelled, let cacheDirectory,
|
||||
let data = try? JSONEncoder().encode(value), data.count <= Self.cacheLimit else { return }
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
|
||||
try data.write(to: cacheDirectory.appendingPathComponent(name), options: .atomic)
|
||||
} catch {
|
||||
// 缓存写入失败不使已验证的公开参考数据失效,下一次启动重新获取。
|
||||
}
|
||||
}
|
||||
|
||||
private static func download(_ url: URL, limit: Int) async throws -> Data {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.timeoutIntervalForRequest = 15
|
||||
configuration.timeoutIntervalForResource = 15
|
||||
configuration.urlCache = nil
|
||||
configuration.httpCookieStorage = nil
|
||||
configuration.httpShouldSetCookies = false
|
||||
let session = URLSession(configuration: configuration)
|
||||
defer { session.invalidateAndCancel() }
|
||||
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
let (bytes, response) = try await session.bytes(for: request)
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
||||
throw AICostReferenceError.invalidResponse
|
||||
}
|
||||
guard response.expectedContentLength <= Int64(limit) else { throw AICostReferenceError.responseTooLarge }
|
||||
var data = Data()
|
||||
for try await byte in bytes {
|
||||
guard data.count < limit else { throw AICostReferenceError.responseTooLarge }
|
||||
data.append(byte)
|
||||
if data.count % 16_384 == 0 { try Task.checkCancellation() }
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
return data
|
||||
}
|
||||
}
|
||||
@@ -45,10 +45,12 @@ public struct LocalUsageReport: Equatable, Sendable {
|
||||
rows.reduce(into: ModelTokenUsage(day: .distantPast, model: "")) { $0.add($1) }
|
||||
}
|
||||
|
||||
/// 活动图模式同时控制摘要:每日取本日,每周取本周,累计取本月;热力图仍单独取全年。
|
||||
/// 活动图模式同时控制摘要:每日取本日,每周取本周,累计与活动图共用最近 365 天。
|
||||
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
|
||||
// 累计摘要、费用和排行必须覆盖图表同一窗口,不能仅从本月月初开始。
|
||||
if mode == .cumulative { return selected(days: 365, model: model, now: now, calendar: calendar) }
|
||||
let component: Calendar.Component = mode == .daily ? .day : .weekOfYear
|
||||
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) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Foundation
|
||||
|
||||
/// AI 费用的显示币种;底层统计始终保存美元金额。
|
||||
public enum AIUsageCurrency: String, CaseIterable, Codable, Sendable {
|
||||
case usd
|
||||
case cny
|
||||
|
||||
public var currencyCode: String {
|
||||
switch self {
|
||||
case .usd: "USD"
|
||||
case .cny: "CNY"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 主应用与 Widget 共用的纯显示格式化,不读取或修改全局语言偏好。
|
||||
public enum AIUsageFormat {
|
||||
/// 按指定语言缩写 token 数;中文默认使用万、亿,西式单位为 K、M、B。
|
||||
public static func tokens(_ value: Int, westernUnits: Bool = false, locale: Locale) -> String {
|
||||
let number = Double(value)
|
||||
let magnitude = abs(number)
|
||||
let divisor: Double
|
||||
let suffix: String
|
||||
let fractionDigits: Int
|
||||
|
||||
if !westernUnits, locale.language.languageCode?.identifier == "zh" {
|
||||
let traditional = locale.language.script?.identifier == "Hant"
|
||||
// 在低档位舍入会抵达下一单位时提前升档,避免显示 10000 万。
|
||||
if magnitude >= 99_999_500 {
|
||||
divisor = 100_000_000
|
||||
suffix = traditional ? "億" : "亿"
|
||||
fractionDigits = 2
|
||||
} else if magnitude >= 10_000 {
|
||||
divisor = 10_000
|
||||
suffix = traditional ? "萬" : "万"
|
||||
fractionDigits = 1
|
||||
} else {
|
||||
return value.formatted(.number.locale(locale).grouping(.never))
|
||||
}
|
||||
} else {
|
||||
// K/M 的大数档取整;跨界前提前升档,避免 1000K 和 1000M。
|
||||
if magnitude >= 999_500_000 {
|
||||
divisor = 1_000_000_000
|
||||
suffix = "B"
|
||||
fractionDigits = 2
|
||||
} else if magnitude >= 999_500 {
|
||||
divisor = 1_000_000
|
||||
suffix = "M"
|
||||
fractionDigits = magnitude >= 10_000_000 ? 0 : 1
|
||||
} else if magnitude >= 1_000 {
|
||||
divisor = 1_000
|
||||
suffix = "K"
|
||||
fractionDigits = magnitude >= 100_000 ? 0 : 1
|
||||
} else {
|
||||
return value.formatted(.number.locale(locale).grouping(.never))
|
||||
}
|
||||
}
|
||||
|
||||
return (number / divisor).formatted(
|
||||
.number.locale(locale).grouping(.never).precision(.fractionLength(0...fractionDigits))
|
||||
) + suffix
|
||||
}
|
||||
|
||||
/// 将有效剩余额度限制到 0...100,再按显示方向返回剩余或已用百分数。
|
||||
public static func quotaValue(remainingPercent: Double, showsRemaining: Bool) -> Double? {
|
||||
guard remainingPercent.isFinite else { return nil }
|
||||
let remaining = min(100, max(0, remainingPercent))
|
||||
return showsRemaining ? remaining : 100 - remaining
|
||||
}
|
||||
|
||||
/// 格式化额度百分数;未知值显示破折号,紧凑显示保留接近空额、满额的区别。
|
||||
public static func quotaPercent(
|
||||
remainingPercent: Double, showsRemaining: Bool, locale: Locale, compact: Bool = false
|
||||
) -> String {
|
||||
guard let value = quotaValue(remainingPercent: remainingPercent, showsRemaining: showsRemaining) else {
|
||||
return "—"
|
||||
}
|
||||
let style = FloatingPointFormatStyle<Double>.Percent(locale: locale)
|
||||
.precision(.fractionLength(0...(compact ? 0 : 1)))
|
||||
if compact, value > 0, value < 1 {
|
||||
return "<" + 0.01.formatted(style)
|
||||
}
|
||||
if compact, value > 99, value < 100 {
|
||||
return ">" + 0.99.formatted(style)
|
||||
}
|
||||
return (value / 100).formatted(style)
|
||||
}
|
||||
|
||||
/// 仅在显示时兑换美元金额;无效金额、缺失汇率或乘法上溢返回 nil。
|
||||
/// 此接口仅接受美元费用,不用于 credits 等非货币计量。
|
||||
public static func money(
|
||||
usd: Double, currency: AIUsageCurrency, usdToCNY: Double?, locale: Locale
|
||||
) -> String? {
|
||||
guard usd.isFinite, usd >= 0 else { return nil }
|
||||
let amount: Double
|
||||
switch currency {
|
||||
case .usd:
|
||||
amount = usd
|
||||
case .cny:
|
||||
guard let rate = usdToCNY, rate.isFinite, rate > 0 else { return nil }
|
||||
amount = usd * rate
|
||||
}
|
||||
guard amount.isFinite else { return nil }
|
||||
let fractionDigits = amount >= 100 ? 0 : amount >= 1 ? 2 : 3
|
||||
return amount.formatted(
|
||||
.currency(code: currency.currencyCode).locale(locale)
|
||||
.precision(.fractionLength(0...fractionDigits))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1620,3 +1620,19 @@ GitHub 项目 مشروع GitHub
|
||||
不能在 XStats 内卸载或退出 XStats 自身 لا يمكن لهذه الأداة إلغاء تثبيت XStats نفسه أو إنهاؤه
|
||||
应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 تعذر نقل التطبيق. نُقلت {} من العناصر المرتبطة إلى سلة المهملات (نحو {}). عالج الخطأ ثم أعد المحاولة.
|
||||
黄历 التقويم الصيني التقليدي
|
||||
额度显示 عرض الحصة
|
||||
数字单位 وحدات الأرقام
|
||||
万 / 亿 ١٠ آلاف / ١٠٠ مليون
|
||||
货币 العملة
|
||||
美元 الدولار الأمريكي
|
||||
人民币 اليوان الصيني
|
||||
估算费用 التكلفة التقديرية
|
||||
暂无汇率 سعر الصرف غير متاح
|
||||
暂无价格 الأسعار غير متاحة
|
||||
按模型基础 API 单价估算,不含阶梯加价,非订阅账单。 تقدير وفق أسعار API الأساسية لكل نموذج، دون الزيادات حسب الشرائح. ليس فاتورة اشتراك.
|
||||
部分模型暂无价格 الأسعار غير متاحة لبعض النماذج
|
||||
价格更新:{} تحديث الأسعار: {}
|
||||
参考汇率:1 USD ≈ {} CNY سعر الصرف المرجعي: 1 USD ≈ {} CNY
|
||||
汇率更新:{} تحديث سعر الصرف: {}
|
||||
价格来源:models.dev;人民币按缓存汇率换算。 مصدر الأسعار: models.dev؛ تُحوَّل المبالغ إلى اليوان الصيني بسعر الصرف المخزّن مؤقتًا.
|
||||
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 استخدام الذكاء الاصطناعي والحصة يشتركان في هذا الفاصل؛ يُحدَّث شريط القوائم عند وصول بيانات جديدة.
|
||||
|
@@ -1620,3 +1620,19 @@ GitHub 项目 GitHub-Projekt
|
||||
不能在 XStats 内卸载或退出 XStats 自身 XStats kann sich mit diesem Werkzeug nicht selbst deinstallieren oder beenden
|
||||
应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 Die App konnte nicht verschoben werden. Zugehörige Objekte im Papierkorb: {} (ca. {}). Beheben Sie den Fehler und versuchen Sie es erneut.
|
||||
黄历 Chinesischer Almanach
|
||||
额度显示 Kontingentanzeige
|
||||
数字单位 Zahleneinheiten
|
||||
万 / 亿 10 Tsd. / 100 Mio.
|
||||
货币 Währung
|
||||
美元 US-Dollar
|
||||
人民币 Chinesischer Yuan
|
||||
估算费用 Geschätzte Kosten
|
||||
暂无汇率 Wechselkurs nicht verfügbar
|
||||
暂无价格 Preise nicht verfügbar
|
||||
按模型基础 API 单价估算,不含阶梯加价,非订阅账单。 Schätzung anhand der API-Basispreise je Modell, ohne gestaffelte Aufschläge. Keine Abonnementrechnung.
|
||||
部分模型暂无价格 Für einige Modelle sind keine Preise verfügbar
|
||||
价格更新:{} Preisaktualisierung: {}
|
||||
参考汇率:1 USD ≈ {} CNY Referenzkurs: 1 USD ≈ {} CNY
|
||||
汇率更新:{} Wechselkursaktualisierung: {}
|
||||
价格来源:models.dev;人民币按缓存汇率换算。 Preisquelle: models.dev; Yuan-Beträge werden mit dem gespeicherten Wechselkurs umgerechnet.
|
||||
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 KI-Nutzung und Kontingent teilen dieses Intervall; Die Menüleiste wird aktualisiert, sobald neue Daten vorliegen.
|
||||
|
@@ -1620,3 +1620,19 @@ GitHub 项目 Proyecto en GitHub
|
||||
不能在 XStats 内卸载或退出 XStats 自身 XStats no puede desinstalarse ni cerrarse desde esta herramienta
|
||||
应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 No se pudo mover la app. Se movieron {} elementos relacionados a la Papelera (aprox. {}). Resuelve el error e inténtalo de nuevo.
|
||||
黄历 Almanaque chino
|
||||
额度显示 Visualización de cuota
|
||||
数字单位 Unidades numéricas
|
||||
万 / 亿 10 mil / 100 millones
|
||||
货币 Moneda
|
||||
美元 Dólar estadounidense
|
||||
人民币 Yuan chino
|
||||
估算费用 Coste estimado
|
||||
暂无汇率 Tipo de cambio no disponible
|
||||
暂无价格 Precios no disponibles
|
||||
按模型基础 API 单价估算,不含阶梯加价,非订阅账单。 Estimación basada en los precios base de la API de cada modelo, sin recargos por tramos. No es una factura de suscripción.
|
||||
部分模型暂无价格 Precios no disponibles para algunos modelos
|
||||
价格更新:{} Precios actualizados: {}
|
||||
参考汇率:1 USD ≈ {} CNY Tipo de referencia: 1 USD ≈ {} CNY
|
||||
汇率更新:{} Tipo de cambio actualizado: {}
|
||||
价格来源:models.dev;人民币按缓存汇率换算。 Fuente de precios: models.dev; Los importes en yuanes se convierten con el tipo de cambio almacenado.
|
||||
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 El uso de IA y la cuota comparten este intervalo; La barra de menús se actualiza cuando llegan nuevos datos.
|
||||
|
@@ -1620,3 +1620,19 @@ GitHub 项目 Projet GitHub
|
||||
不能在 XStats 内卸载或退出 XStats 自身 XStats ne peut pas se désinstaller ou se quitter depuis cet outil
|
||||
应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 L’app n’a pas pu être déplacée. Éléments associés placés dans la Corbeille : {} (environ {}). Corrigez l’erreur et réessayez.
|
||||
黄历 Almanach chinois
|
||||
额度显示 Affichage du quota
|
||||
数字单位 Unités numériques
|
||||
万 / 亿 10 mille / 100 millions
|
||||
货币 Devise
|
||||
美元 Dollar américain
|
||||
人民币 Yuan chinois
|
||||
估算费用 Coût estimé
|
||||
暂无汇率 Taux de change indisponible
|
||||
暂无价格 Tarifs indisponibles
|
||||
按模型基础 API 单价估算,不含阶梯加价,非订阅账单。 Estimation selon les tarifs API de base de chaque modèle, hors majorations par palier. Il ne s’agit pas d’une facture d’abonnement.
|
||||
部分模型暂无价格 Tarifs indisponibles pour certains modèles
|
||||
价格更新:{} Tarifs mis à jour : {}
|
||||
参考汇率:1 USD ≈ {} CNY Taux de référence : 1 USD ≈ {} CNY
|
||||
汇率更新:{} Taux de change mis à jour : {}
|
||||
价格来源:models.dev;人民币按缓存汇率换算。 Source des tarifs : models.dev; Les montants en yuans sont convertis au taux de change en cache.
|
||||
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 L’utilisation de l’IA et le quota partagent cet intervalle; La barre des menus est mise à jour à l’arrivée de nouvelles données.
|
||||
|
@@ -1620,3 +1620,19 @@ GitHub 项目 GitHub プロジェクト
|
||||
不能在 XStats 内卸载或退出 XStats 自身 このツールからXStats自身をアンインストールまたは終了することはできません
|
||||
应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 アプリ本体を移動できませんでした。関連項目を{}件(約{})ゴミ箱に移動しました。エラーを解決して再試行してください。
|
||||
黄历 中国の伝統暦
|
||||
额度显示 割り当ての表示
|
||||
数字单位 数値の単位
|
||||
万 / 亿 万 / 億
|
||||
货币 通貨
|
||||
美元 米ドル
|
||||
人民币 人民元
|
||||
估算费用 推定費用
|
||||
暂无汇率 為替レートなし
|
||||
暂无价格 料金情報なし
|
||||
按模型基础 API 单价估算,不含阶梯加价,非订阅账单。 モデルごとの基本 API 単価で推定し、段階的な追加料金は含みません。サブスクリプションの請求額ではありません。
|
||||
部分模型暂无价格 一部のモデルの料金情報がありません
|
||||
价格更新:{} 料金更新:{}
|
||||
参考汇率:1 USD ≈ {} CNY 参考レート:1 USD ≈ {} CNY
|
||||
汇率更新:{} 為替レート更新:{}
|
||||
价格来源:models.dev;人民币按缓存汇率换算。 料金情報元:models.dev;人民元はキャッシュされた為替レートで換算します。
|
||||
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 AI 使用量と割り当てでこの間隔を共用;新しいデータの取得後にメニューバーを更新します。
|
||||
|
@@ -1620,3 +1620,19 @@ GitHub 项目 GitHub 프로젝트
|
||||
不能在 XStats 内卸载或退出 XStats 自身 이 도구에서 XStats 자체를 제거하거나 종료할 수 없습니다
|
||||
应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 앱 본체를 이동하지 못했습니다. 관련 항목 {}개(약 {})를 휴지통으로 이동했습니다. 오류를 해결한 후 다시 시도하세요.
|
||||
黄历 중국 전통 역서
|
||||
额度显示 할당량 표시
|
||||
数字单位 숫자 단위
|
||||
万 / 亿 만 / 억
|
||||
货币 통화
|
||||
美元 미국 달러
|
||||
人民币 중국 위안
|
||||
估算费用 예상 비용
|
||||
暂无汇率 환율 정보 없음
|
||||
暂无价格 가격 정보 없음
|
||||
按模型基础 API 单价估算,不含阶梯加价,非订阅账单。 모델별 기본 API 단가로 추정하며 구간별 추가 요금은 포함하지 않습니다. 구독 청구액이 아닙니다.
|
||||
部分模型暂无价格 일부 모델의 가격 정보 없음
|
||||
价格更新:{} 가격 업데이트: {}
|
||||
参考汇率:1 USD ≈ {} CNY 참고 환율: 1 USD ≈ {} CNY
|
||||
汇率更新:{} 환율 업데이트: {}
|
||||
价格来源:models.dev;人民币按缓存汇率换算。 가격 출처: models.dev; 중국 위안 금액은 캐시된 환율로 환산합니다.
|
||||
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 AI 사용량과 할당량에 동일한 간격 적용; 새 데이터가 도착하면 메뉴 막대가 업데이트됩니다.
|
||||
|
@@ -1621,3 +1621,19 @@ GitHub 项目 GitHub 專案
|
||||
不能在 XStats 内卸载或退出 XStats 自身 不能在 XStats 內卸載或退出 XStats 自身
|
||||
应用本体未能移动;已将 {} 项残留移到废纸篓,约 {}。请解决错误后重试。 應用本體未能移動;已將 {} 項殘留移到廢紙簍,約 {}。請解決錯誤後重試。
|
||||
黄历 黃曆
|
||||
额度显示 額度顯示
|
||||
数字单位 數字單位
|
||||
万 / 亿 萬 / 億
|
||||
货币 貨幣
|
||||
美元 美元
|
||||
人民币 人民幣
|
||||
估算费用 估算費用
|
||||
暂无汇率 暫無匯率
|
||||
暂无价格 暫無價格
|
||||
按模型基础 API 单价估算,不含阶梯加价,非订阅账单。 按模型基礎 API 單價估算,不含階梯加價,非訂閱帳單。
|
||||
部分模型暂无价格 部分模型暫無價格
|
||||
价格更新:{} 價格更新:{}
|
||||
参考汇率:1 USD ≈ {} CNY 參考匯率:1 USD ≈ {} CNY
|
||||
汇率更新:{} 匯率更新:{}
|
||||
价格来源:models.dev;人民币按缓存汇率换算。 價格來源:models.dev;人民幣按快取匯率換算。
|
||||
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 AI 用量與額度共用此間隔;新資料到達後更新選單列。
|
||||
|
@@ -81,6 +81,22 @@ Token 活动 Token activity
|
||||
截至 {} 当周累计:{} Tokens Through the week of {}: {} Tokens cumulative
|
||||
缓存创建 Token Cache creation tokens
|
||||
AI 用量 AI Usage
|
||||
额度显示 Quota display
|
||||
数字单位 Number units
|
||||
万 / 亿 10K / 100M
|
||||
货币 Currency
|
||||
美元 US dollar
|
||||
人民币 Chinese yuan
|
||||
估算费用 Estimated cost
|
||||
暂无汇率 Exchange rate unavailable
|
||||
暂无价格 Pricing unavailable
|
||||
按模型基础 API 单价估算,不含阶梯加价,非订阅账单。 Estimated from base API prices per model, excluding tiered surcharges; this is not a subscription bill.
|
||||
部分模型暂无价格 Pricing unavailable for some models
|
||||
价格更新:{} Prices updated: {}
|
||||
参考汇率:1 USD ≈ {} CNY Reference rate: 1 USD ≈ {} CNY
|
||||
汇率更新:{} Exchange rate updated: {}
|
||||
价格来源:models.dev;人民币按缓存汇率换算。 Pricing source: models.dev; Chinese yuan amounts use the cached exchange rate.
|
||||
AI 用量与额度共用此间隔;新数据到达后更新菜单栏。 AI usage and quota share this interval; The menu bar updates when new data arrives.
|
||||
显示本地用量 Show local usage
|
||||
读取本机会话日志;关闭后停止扫描并隐藏 Token 统计。 Reads local session logs; turning this off stops scanning and hides token statistics.
|
||||
查看 Codex / Claude 的 Token 用量与订阅额度。 View Codex / Claude token usage and subscription quotas.
|
||||
|
||||
@@ -161,6 +161,9 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable {
|
||||
public var dailyTokens: [DailyTokenUsage]?
|
||||
/// 可选以兼容旧版摘要;缺失时沿用 AI 总开关的旧行为。
|
||||
public var localUsageEnabled: Bool?
|
||||
/// 可选显示偏好;旧摘要沿用剩余额度和按语言缩写。
|
||||
public var quotaShowsRemaining: Bool?
|
||||
public var westernUnits: Bool?
|
||||
public var publicIPEnabled: Bool
|
||||
public var addresses: [Address]
|
||||
public var language: String
|
||||
@@ -177,7 +180,7 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable {
|
||||
/// 写入日历摘要的主应用构建号;新版可能内置新的放假数据,构建号不同时不复用缓存。
|
||||
public var calendarDataVersion: String?
|
||||
public init(aiEnabled: Bool = false, quotas: [Quota] = [], dailyTokens: [DailyTokenUsage]? = nil,
|
||||
localUsageEnabled: Bool? = nil,
|
||||
localUsageEnabled: Bool? = nil, quotaShowsRemaining: Bool? = nil, westernUnits: Bool? = nil,
|
||||
publicIPEnabled: Bool = false,
|
||||
addresses: [Address] = [], language: String = "system",
|
||||
calendarFirstWeekday: Int = Calendar.current.firstWeekday, showsLunar: Bool = Locale.current.region?.identifier == "CN",
|
||||
@@ -187,6 +190,8 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable {
|
||||
self.quotas = quotas
|
||||
self.dailyTokens = dailyTokens
|
||||
self.localUsageEnabled = localUsageEnabled
|
||||
self.quotaShowsRemaining = quotaShowsRemaining
|
||||
self.westernUnits = westernUnits
|
||||
self.publicIPEnabled = publicIPEnabled
|
||||
self.addresses = addresses
|
||||
self.language = language
|
||||
@@ -225,7 +230,9 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable {
|
||||
}
|
||||
let relocalized = language != previous.language
|
||||
let quotaChanged = relocalized || aiEnabled != previous.aiEnabled
|
||||
|| (quotaShowsRemaining != false) != (previous.quotaShowsRemaining != false)
|
||||
let localUsageChanged = relocalized || showsLocalUsage != previous.showsLocalUsage
|
||||
|| (westernUnits == true) != (previous.westernUnits == true)
|
||||
var kinds: [String] = []
|
||||
if quotaChanged || displayed(quotas) != displayed(previous.quotas) { kinds.append(WidgetKind.aiQuota) }
|
||||
if localUsageChanged || dailyTokens != previous.dailyTokens { kinds.append(WidgetKind.todayTokens) }
|
||||
|
||||
@@ -382,6 +382,8 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
withObservationTracking {
|
||||
_ = model.aiUsage.states
|
||||
_ = model.aiUsage.lastAttemptAt
|
||||
_ = model.settings.aiQuotaShowsRemaining
|
||||
_ = model.settings.aiUsageWesternUnits
|
||||
} onChange: { [weak self] in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
@@ -397,6 +399,8 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
withObservationTracking {
|
||||
_ = model.settings.aiUsageEnabled
|
||||
_ = model.settings.aiUsageSources
|
||||
_ = model.settings.aiQuotaShowsRemaining
|
||||
_ = model.settings.aiUsageWesternUnits
|
||||
_ = model.settings.aiUsageShowsLocalUsage
|
||||
_ = model.settings.publicIPLookup
|
||||
_ = model.settings.language
|
||||
@@ -484,6 +488,8 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
let snapshot = WidgetSnapshot(aiEnabled: settings.aiUsageEnabled, quotas: quotas,
|
||||
dailyTokens: dailyTokens,
|
||||
localUsageEnabled: settings.aiUsageShowsLocalUsage,
|
||||
quotaShowsRemaining: settings.aiQuotaShowsRemaining,
|
||||
westernUnits: settings.aiUsageWesternUnits,
|
||||
publicIPEnabled: settings.publicIPLookup, addresses: addresses,
|
||||
language: settings.language.rawValue,
|
||||
calendarFirstWeekday: settings.calendarFirstWeekday,
|
||||
|
||||
@@ -48,6 +48,8 @@ struct MenuBarReading {
|
||||
/// 本机日志今日 Token 总量;扫描未开启或还没有数据时为 nil
|
||||
var aiTokens: Int?
|
||||
var aiUsageShowsLocalUsage = true
|
||||
var aiQuotaShowsRemaining = true
|
||||
var aiUsageWesternUnits = false
|
||||
/// 只有可显示额度的来源才占用菜单栏读数;本机 Token 统计独立于订阅额度。
|
||||
var aiQuotas: [MenuBarQuota] = []
|
||||
var batteryCharging = false
|
||||
@@ -97,6 +99,8 @@ struct MenuBarReading {
|
||||
case .aiUsage:
|
||||
aiTokens = model.aiUsage.todayTokens
|
||||
aiUsageShowsLocalUsage = model.settings.aiUsageShowsLocalUsage
|
||||
aiQuotaShowsRemaining = model.settings.aiQuotaShowsRemaining
|
||||
aiUsageWesternUnits = model.settings.aiUsageWesternUnits
|
||||
aiQuotas = model.aiUsage.visibleQuotaProviders(for: nil).compactMap { provider in
|
||||
let state = model.aiUsage.visibleQuotaState(for: provider)
|
||||
guard let snapshot = state.snapshot else { return nil }
|
||||
@@ -178,10 +182,11 @@ struct MenuBarReading {
|
||||
&& (style != .history && style != .line || batteryHistory == previous.batteryHistory)
|
||||
case .aiUsage:
|
||||
guard aiQuotas.isEmpty == previous.aiQuotas.isEmpty else { return false }
|
||||
if aiQuotas.isEmpty { return aiTokens == previous.aiTokens }
|
||||
if aiQuotas.isEmpty { return aiTokens == previous.aiTokens && aiUsageWesternUnits == previous.aiUsageWesternUnits }
|
||||
guard aiQuotaShowsRemaining == previous.aiQuotaShowsRemaining else { return false }
|
||||
return aiQuotas.elementsEqual(previous.aiQuotas) {
|
||||
$0.provider == $1.provider && $0.shortWindowName == $1.shortWindowName
|
||||
&& $0.remainingPercent == $1.remainingPercent
|
||||
&& $0.window.remainingPercent == $1.window.remainingPercent
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,7 +217,7 @@ struct MenuBarReading {
|
||||
let checked = quota.isStale
|
||||
? " · \(tr("上次成功:\(quota.fetchedAt.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale)))"))"
|
||||
: ""
|
||||
return "\(previous)\(quota.sourceName)\(quota.source == .sub2api ? " (Sub2API)" : "") · \(quota.shortWindowName) · \(tr("剩余")) \(quota.remainingPercent)% · \(tr("重置:")) \(quota.resetText)\(checked)"
|
||||
return "\(previous)\(quota.sourceName)\(quota.source == .sub2api ? " (Sub2API)" : "") · \(quota.shortWindowName) · \(tr(aiQuotaShowsRemaining ? "剩余" : "已用")) \(AIUsageFormat.quotaPercent(remainingPercent: quota.window.remainingPercent, showsRemaining: aiQuotaShowsRemaining, locale: L10n.locale)) · \(tr("重置:")) \(quota.resetText)\(checked)"
|
||||
}.joined(separator: "\n")
|
||||
} else {
|
||||
aiTokens.map { "AI · \(UsageNumber.exact($0)) Tokens" }
|
||||
@@ -227,8 +232,8 @@ struct MenuBarReading {
|
||||
/// 菜单栏图标自绘:每次刷新只生成一张小图,避免在菜单栏里重建 SwiftUI 视图
|
||||
@MainActor
|
||||
enum MenuBarRenderer {
|
||||
private static func tokenText(_ value: Int) -> String {
|
||||
UsageNumber.short(value)
|
||||
private static func tokenText(_ value: Int, westernUnits: Bool) -> String {
|
||||
AIUsageFormat.tokens(value, westernUnits: westernUnits, locale: L10n.locale)
|
||||
}
|
||||
/// 菜单栏只有 22pt 高。额度读数单独使用更清晰的字号,其余指标保持紧凑排版。
|
||||
private enum Metrics {
|
||||
@@ -373,17 +378,17 @@ enum MenuBarRenderer {
|
||||
|
||||
private static func aiUsageSegment(reading: MenuBarReading, style: MenuBarStyle) -> Segment {
|
||||
guard !reading.aiQuotas.isEmpty else {
|
||||
return textSegment(item: .aiUsage, value: reading.aiTokens.map { tokenText($0) } ?? "—",
|
||||
return textSegment(item: .aiUsage, value: reading.aiTokens.map { tokenText($0, westernUnits: reading.aiUsageWesternUnits) } ?? "—",
|
||||
sample: "999.9M", style: style)
|
||||
}
|
||||
let status = combine(reading.aiQuotas.map { quota in
|
||||
let provider = quota.provider
|
||||
let name = provider == .codex ? "Codex" : "Claude"
|
||||
let label = "\(name) · \(quota.shortWindowName)"
|
||||
let value = "\(quota.remainingPercent)%"
|
||||
let value = AIUsageFormat.quotaPercent(remainingPercent: quota.window.remainingPercent, showsRemaining: reading.aiQuotaShowsRemaining, locale: L10n.locale, compact: true)
|
||||
let stacked = aiQuotaText(label: name, value: value, centered: style == .stackedCenter)
|
||||
let inline = aiQuotaInlineText(label: name, value: value)
|
||||
let fraction = min(1, max(0, Double(quota.remainingPercent) / 100))
|
||||
let fraction = (AIUsageFormat.quotaValue(remainingPercent: quota.window.remainingPercent, showsRemaining: reading.aiQuotaShowsRemaining) ?? 0) / 100
|
||||
let color = aiQuotaColor(quota.remainingPercent)
|
||||
switch style {
|
||||
case .stacked, .stackedCenter: return stacked
|
||||
|
||||
@@ -5,6 +5,18 @@ import AIUsage
|
||||
import Localization
|
||||
import SwiftUI
|
||||
|
||||
extension EnvironmentValues {
|
||||
@Entry var aiUsageWesternUnits = false
|
||||
}
|
||||
|
||||
private struct CostRefreshKey: Hashable {
|
||||
let attempt: Date?
|
||||
let currency: AIUsageCurrency
|
||||
let enabled: Bool
|
||||
let localUsage: Bool
|
||||
let visible: Bool
|
||||
}
|
||||
|
||||
struct AIUsagePage: View {
|
||||
var body: some View { PageScroll { LocalUsageContent(compact: false) } }
|
||||
}
|
||||
@@ -13,6 +25,7 @@ struct AIUsagePopover: View {
|
||||
}
|
||||
|
||||
private struct LocalUsageContent: View {
|
||||
@Environment(\.isSnapshot) private var isSnapshot
|
||||
let compact: Bool
|
||||
@Environment(AppModel.self) private var model
|
||||
@State private var selectedModel = ""
|
||||
@@ -37,6 +50,11 @@ private struct LocalUsageContent: View {
|
||||
.map { $0 == .codex ? "Codex" : "Claude Code" }.joined(separator: " + ")
|
||||
}
|
||||
|
||||
private var isVisible: Bool {
|
||||
compact ? model.openPopover == .aiUsage
|
||||
: model.isMainWindowVisible && model.settings.panelTab == .aiUsage
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: compact ? DS.Space.s3 : DS.Space.s4) {
|
||||
toolbar
|
||||
@@ -69,7 +87,8 @@ private struct LocalUsageContent: View {
|
||||
let activityRows = report.selected(days: 365, model: selectedModel.isEmpty ? nil : selectedModel)
|
||||
let total = LocalUsageReport.total(rows)
|
||||
UsageSummary(total: total, compact: compact,
|
||||
title: selectedModel.isEmpty ? tr("本地用量") : selectedModel)
|
||||
title: selectedModel.isEmpty ? tr("本地用量") : selectedModel,
|
||||
costRows: rows)
|
||||
UsageHeatmap(rows: activityRows, compact: compact, mode: $activityMode)
|
||||
modelRanking(report: report, rows: rows, total: total)
|
||||
status(report)
|
||||
@@ -87,6 +106,13 @@ private struct LocalUsageContent: View {
|
||||
Card { Text(tr("暂无额度数据")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) }
|
||||
}
|
||||
}
|
||||
.environment(\.aiUsageWesternUnits, model.settings.aiUsageWesternUnits)
|
||||
.task(id: CostRefreshKey(attempt: model.aiUsage.lastAttemptAt, currency: model.settings.aiUsageCurrency,
|
||||
enabled: model.settings.aiUsageEnabled, localUsage: model.settings.aiUsageShowsLocalUsage,
|
||||
visible: isVisible)) {
|
||||
guard !isSnapshot, isVisible else { return }
|
||||
await model.aiUsage.refreshCostReferences()
|
||||
}
|
||||
.onChange(of: model.settings.aiUsageSources) { _, sources in
|
||||
if let id = AIProviderID(rawValue: source), !sources.contains(id) { source = "all" }
|
||||
selectedModel = ""
|
||||
@@ -147,16 +173,22 @@ private struct LocalUsageContent: View {
|
||||
Text(quotaTitle(window.kind))
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
Spacer()
|
||||
Text(tr("剩余"))
|
||||
Text(tr(model.settings.aiQuotaShowsRemaining ? "剩余" : "已用"))
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
Text("\(Int(window.remainingPercent.rounded()))%")
|
||||
Text(AIUsageFormat.quotaPercent(remainingPercent: window.remainingPercent,
|
||||
showsRemaining: model.settings.aiQuotaShowsRemaining,
|
||||
locale: L10n.locale))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
}
|
||||
.dsFont(.xs)
|
||||
ProgressTrack(fraction: window.remainingPercent / 100,
|
||||
ProgressTrack(fraction: (AIUsageFormat.quotaValue(remainingPercent: window.remainingPercent,
|
||||
showsRemaining: model.settings.aiQuotaShowsRemaining) ?? 0) / 100,
|
||||
color: progressTint(for: window.remainingPercent))
|
||||
.accessibilityLabel("\(quotaTitle(window.kind)) \(tr("剩余")) \(Int(window.remainingPercent.rounded()))%")
|
||||
.accessibilityLabel(quotaTitle(window.kind) + " " + tr(model.settings.aiQuotaShowsRemaining ? "剩余" : "已用")
|
||||
+ " " + AIUsageFormat.quotaPercent(remainingPercent: window.remainingPercent,
|
||||
showsRemaining: model.settings.aiQuotaShowsRemaining,
|
||||
locale: L10n.locale))
|
||||
if let reset = window.resetsAt {
|
||||
let resetText = tr("重置:") + " " + reset.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale))
|
||||
if state.isStale {
|
||||
@@ -384,25 +416,17 @@ enum UsageNumber {
|
||||
date.formatted(.dateTime.year().month().day().locale(locale))
|
||||
}
|
||||
|
||||
static func short(_ value: Int, locale: Locale = L10n.locale) -> String {
|
||||
let chinese = locale.language.languageCode?.identifier == "zh"
|
||||
let units: [(Int, String)] = chinese
|
||||
? [(100_000_000, "亿"), (1_000_000, "百万"), (10_000, "万"), (1_000, "千")]
|
||||
: [(1_000_000_000, "B"), (1_000_000, "M"), (1_000, "K")]
|
||||
guard let (divisor, rawUnit) = units.first(where: { value >= $0.0 }) else {
|
||||
return value.formatted(.number.locale(locale))
|
||||
}
|
||||
let unit = locale.language.script?.identifier == "Hant"
|
||||
? rawUnit.replacingOccurrences(of: "亿", with: "億").replacingOccurrences(of: "万", with: "萬") : rawUnit
|
||||
let number = (Double(value) / Double(divisor)).formatted(.number.precision(.fractionLength(0...1)).locale(locale))
|
||||
return number + (chinese ? " " : "") + unit
|
||||
static func short(_ value: Int, locale: Locale = L10n.locale, westernUnits: Bool = false) -> String {
|
||||
AIUsageFormat.tokens(value, westernUnits: westernUnits, locale: locale)
|
||||
}
|
||||
}
|
||||
|
||||
private struct UsageSummary: View {
|
||||
@Environment(\.aiUsageWesternUnits) private var westernUnits
|
||||
let total: ModelTokenUsage
|
||||
let compact: Bool
|
||||
let title: String
|
||||
let costRows: [ModelTokenUsage]
|
||||
@State private var expanded = false
|
||||
|
||||
var body: some View {
|
||||
@@ -412,7 +436,7 @@ private struct UsageSummary: View {
|
||||
Spacer()
|
||||
}
|
||||
HStack(alignment: .firstTextBaseline, spacing: DS.Space.s2) {
|
||||
Text(UsageNumber.short(total.total))
|
||||
Text(UsageNumber.short(total.total, westernUnits: westernUnits))
|
||||
.dsFont(.xxl, weight: .semibold)
|
||||
.tracking(-0.6).monospacedDigit()
|
||||
.help(UsageNumber.exact(total.total))
|
||||
@@ -422,12 +446,13 @@ private struct UsageSummary: View {
|
||||
}
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), alignment: .leading), count: compact ? 2 : 4),
|
||||
alignment: .leading, spacing: DS.Space.s3) {
|
||||
UsageMetric(title: tr("输入 Token"), value: UsageNumber.short(total.input), exact: UsageNumber.exact(total.input))
|
||||
UsageMetric(title: tr("输出 Token"), value: UsageNumber.short(total.output), exact: UsageNumber.exact(total.output))
|
||||
UsageMetric(title: tr("输入 Token"), value: UsageNumber.short(total.input, westernUnits: westernUnits), exact: UsageNumber.exact(total.input))
|
||||
UsageMetric(title: tr("输出 Token"), value: UsageNumber.short(total.output, westernUnits: westernUnits), exact: UsageNumber.exact(total.output))
|
||||
UsageMetric(title: tr("缓存命中率"), value: total.input > 0
|
||||
? (Double(total.cached) / Double(total.input)).formatted(.percent.precision(.fractionLength(1)).locale(L10n.locale)) : "—")
|
||||
UsageMetric(title: tr("用量记录"), value: UsageNumber.exact(total.records))
|
||||
}
|
||||
UsageCostSummary(rows: costRows)
|
||||
DisclosureGroup(isExpanded: $expanded) {
|
||||
VStack(spacing: DS.Space.s2) {
|
||||
InfoRow(label: tr("缓存 Token"), text: UsageNumber.exact(total.cached))
|
||||
@@ -443,6 +468,54 @@ private struct UsageSummary: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct UsageCostSummary: View {
|
||||
let rows: [ModelTokenUsage]
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
let estimate = model.aiUsage.estimatedCost(rows)
|
||||
let references = model.aiUsage.costReferences
|
||||
let currency = model.settings.aiUsageCurrency
|
||||
VStack(alignment: .leading, spacing: DS.Space.s1) {
|
||||
HStack {
|
||||
Text(tr("估算费用")).foregroundStyle(DS.Palette.textSecondary)
|
||||
Spacer()
|
||||
if let usd = estimate.usd {
|
||||
if let amount = AIUsageFormat.money(usd: usd, currency: currency,
|
||||
usdToCNY: references.exchangeRate?.cnyPerUSD, locale: L10n.locale) {
|
||||
Text(amount + (estimate.unpricedModels.isEmpty ? "" : "+"))
|
||||
.monospacedDigit().foregroundStyle(DS.Palette.textPrimary)
|
||||
} else {
|
||||
Text(tr("暂无汇率")).foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
} else if model.aiUsage.isRefreshingCostReferences {
|
||||
ProgressView().controlSize(.mini)
|
||||
} else {
|
||||
Text(tr("暂无价格")).foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
}.dsFont(.sm, weight: .medium)
|
||||
Text(tr("按模型基础 API 单价估算,不含阶梯加价,非订阅账单。"))
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if !estimate.unpricedModels.isEmpty {
|
||||
Text(tr("部分模型暂无价格"))
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.warning)
|
||||
.help(estimate.unpricedModels.joined(separator: "\n"))
|
||||
}
|
||||
if let catalog = references.catalog {
|
||||
Text(tr("价格更新:\(catalog.fetchedAt.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale)))"))
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
if currency == .cny, let rate = references.exchangeRate {
|
||||
let value = rate.cnyPerUSD.formatted(.number.precision(.fractionLength(0...4)).locale(L10n.locale))
|
||||
Text(tr("参考汇率:1 USD ≈ \(value) CNY"))
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||||
.help(tr("汇率更新:\(rate.fetchedAt.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale)))"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct UsageMetric: View {
|
||||
let title: String
|
||||
let value: String
|
||||
@@ -458,6 +531,7 @@ private struct UsageMetric: View {
|
||||
}
|
||||
|
||||
private struct UsageModelRow: View {
|
||||
@Environment(\.aiUsageWesternUnits) private var westernUnits
|
||||
let item: ModelTokenUsage
|
||||
let total: Int
|
||||
let compact: Bool
|
||||
@@ -470,7 +544,7 @@ private struct UsageModelRow: View {
|
||||
Text(verbatim: item.model).dsFont(.xs, weight: .medium).lineLimit(1).help(item.model)
|
||||
}
|
||||
Spacer(minLength: DS.Space.s2)
|
||||
Text(UsageNumber.short(item.total)).dsFont(.sm, weight: .semibold)
|
||||
Text(UsageNumber.short(item.total, westernUnits: westernUnits)).dsFont(.sm, weight: .semibold)
|
||||
.monospacedDigit().fixedSize().help(UsageNumber.exact(item.total))
|
||||
.accessibilityLabel(UsageNumber.exact(item.total) + " Tokens")
|
||||
Text((total > 0 ? Double(item.total) / Double(total) : 0).formatted(.percent.precision(.fractionLength(0)).locale(L10n.locale)))
|
||||
@@ -482,8 +556,8 @@ private struct UsageModelRow: View {
|
||||
.accessibilityHidden(true)
|
||||
if !compact {
|
||||
HStack(spacing: DS.Space.s6) {
|
||||
Text(tr("输入 Token") + " " + UsageNumber.short(item.input))
|
||||
Text(tr("输出 Token") + " " + UsageNumber.short(item.output))
|
||||
Text(tr("输入 Token") + " " + UsageNumber.short(item.input, westernUnits: westernUnits))
|
||||
Text(tr("输出 Token") + " " + UsageNumber.short(item.output, westernUnits: westernUnits))
|
||||
Spacer()
|
||||
Text(tr("用量记录") + " " + UsageNumber.exact(item.records))
|
||||
}.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary).monospacedDigit()
|
||||
@@ -492,8 +566,9 @@ private struct UsageModelRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 活动图始终展示全年;模式绑定到父视图,同时切换摘要和排行的本日/本周/本月口径。
|
||||
/// 活动图始终展示最近 365 天;模式同时切换摘要和排行的本日/本周/同窗口累计口径。
|
||||
private struct UsageHeatmap: View {
|
||||
@Environment(\.aiUsageWesternUnits) private var westernUnits
|
||||
let rows: [ModelTokenUsage]
|
||||
let compact: Bool
|
||||
@Binding var mode: UsageActivityMode
|
||||
@@ -547,7 +622,7 @@ private struct UsageHeatmap: View {
|
||||
.frame(width: DS.Space.s3, height: DS.Space.s3)
|
||||
}
|
||||
}.accessibilityHidden(true)
|
||||
Text(UsageNumber.short(peak) + " Tokens").help(UsageNumber.exact(peak) + " Tokens")
|
||||
Text(UsageNumber.short(peak, westernUnits: westernUnits) + " Tokens").help(UsageNumber.exact(peak) + " Tokens")
|
||||
}.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
.onChange(of: mode) { _, _ in hovered = nil }
|
||||
@@ -588,6 +663,7 @@ private struct ActivityGrid: View {
|
||||
}
|
||||
|
||||
private struct ActivityWeek: View {
|
||||
@Environment(\.aiUsageWesternUnits) private var westernUnits
|
||||
let week: UsageActivity.Week
|
||||
let activity: UsageActivity
|
||||
let mode: UsageActivityMode
|
||||
@@ -597,8 +673,8 @@ private struct ActivityWeek: View {
|
||||
private var weekLabel: String {
|
||||
let date = UsageNumber.day(week.start)
|
||||
return mode == .weekly
|
||||
? tr("\(date) 当周:\(UsageNumber.short(week.total)) Tokens")
|
||||
: tr("截至 \(date) 当周累计:\(UsageNumber.short(week.cumulative)) Tokens")
|
||||
? tr("\(date) 当周:\(UsageNumber.short(week.total, westernUnits: westernUnits)) Tokens")
|
||||
: tr("截至 \(date) 当周累计:\(UsageNumber.short(week.cumulative, westernUnits: westernUnits)) Tokens")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -614,7 +690,7 @@ private struct ActivityWeek: View {
|
||||
let level = min(4, max(1, Int(ceil(Double(count) / Double(max(1, peak)) * 4))))
|
||||
ActivityCell(side: side,
|
||||
color: count == 0 ? DS.Palette.track : DS.Palette.primary.opacity(Double(level) / 4),
|
||||
label: UsageNumber.day(date) + " · " + UsageNumber.short(count) + " Tokens",
|
||||
label: UsageNumber.day(date) + " · " + UsageNumber.short(count, westernUnits: westernUnits) + " Tokens",
|
||||
hovered: $hovered)
|
||||
} else {
|
||||
Color.clear.frame(width: side, height: side).accessibilityHidden(true)
|
||||
@@ -720,13 +796,36 @@ private struct UsageSettings: View {
|
||||
}
|
||||
}
|
||||
HairlineDivider()
|
||||
SettingRow(title: tr("刷新间隔"), subtitle: nil) {
|
||||
SettingRow(title: tr("刷新间隔"), subtitle: tr("AI 用量与额度共用此间隔;新数据到达后更新菜单栏。")) {
|
||||
Picker(tr("刷新间隔"), selection: $settings.aiUsageRefreshMinutes) {
|
||||
ForEach(AppSettings.aiUsageRefreshOptions, id: \.self) { value in
|
||||
Text(tr("\(value) 分钟")).tag(value)
|
||||
}
|
||||
}.labelsHidden()
|
||||
}
|
||||
SettingRow(title: tr("额度显示"), subtitle: nil) {
|
||||
Picker(tr("额度显示"), selection: $settings.aiQuotaShowsRemaining) {
|
||||
Text(tr("已用")).tag(false)
|
||||
Text(tr("剩余")).tag(true)
|
||||
}.labelsHidden()
|
||||
}
|
||||
if settings.language.resolved.isChinese {
|
||||
SettingRow(title: tr("数字单位"), subtitle: nil) {
|
||||
Picker(tr("数字单位"), selection: $settings.aiUsageWesternUnits) {
|
||||
Text(tr("万 / 亿")).tag(false)
|
||||
Text("K / M / B").tag(true)
|
||||
}.labelsHidden()
|
||||
}
|
||||
}
|
||||
SettingRow(title: tr("货币"), subtitle: nil) {
|
||||
Picker(tr("货币"), selection: $settings.aiUsageCurrency) {
|
||||
Text(tr("美元")).tag(AIUsageCurrency.usd)
|
||||
Text(tr("人民币")).tag(AIUsageCurrency.cny)
|
||||
}.labelsHidden()
|
||||
}
|
||||
Text(tr("价格来源:models.dev;人民币按缓存汇率换算。"))
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text(tr("本机 Token 统计不代表订阅账单或其他设备的用量"))
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
@@ -313,15 +313,17 @@ private struct OverviewReading {
|
||||
case .aiUsage:
|
||||
if !model.settings.aiUsageShowsLocalUsage {
|
||||
if let quota = MenuBarReading(model: model, items: [.aiUsage]).aiQuotas.min(by: { $0.remainingPercent < $1.remainingPercent }) {
|
||||
value = "\(quota.remainingPercent)"
|
||||
unit = "%"
|
||||
detail = quota.sourceName + " · " + tr("订阅额度")
|
||||
value = AIUsageFormat.quotaPercent(remainingPercent: quota.window.remainingPercent,
|
||||
showsRemaining: model.settings.aiQuotaShowsRemaining,
|
||||
locale: L10n.locale, compact: true)
|
||||
unit = ""
|
||||
detail = quota.sourceName + " · " + tr(model.settings.aiQuotaShowsRemaining ? "剩余" : "已用")
|
||||
} else {
|
||||
detail = tr("暂无额度数据")
|
||||
}
|
||||
} else if let tokens = model.aiUsage.todayTokens {
|
||||
// 与菜单栏、面板共用同一套缩写,同一个数字不能在三处显示成三种样子。
|
||||
value = UsageNumber.short(tokens)
|
||||
value = UsageNumber.short(tokens, westernUnits: model.settings.aiUsageWesternUnits)
|
||||
unit = "Tokens"
|
||||
detail = "AI · " + tr("今天")
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# XStats Privacy Policy
|
||||
|
||||
Effective date: October 2, 2026
|
||||
Effective date: October 3, 2026
|
||||
|
||||
This policy explains what XStats processes on your Mac and which features contact external services. XStats-operated services retain only the update-statistics aggregates described in section 2; the app's other persisted data stays on your Mac and is not uploaded to XStats-operated servers. Network tests send requests needed for the test to independent providers, as described in section 3. Contact: i@xiai.me.
|
||||
|
||||
@@ -26,6 +26,7 @@ You can set automatic checks to Never in Settings → About. You decide whether
|
||||
## 3. Other network features
|
||||
|
||||
- When AI Usage is enabled, Codex quotas are read through the installed local Codex CLI App Server first; if unavailable, XStats uses cached Codex credentials to contact `chatgpt.com` directly. Claude Code uses its local credentials to contact `api.anthropic.com` directly. Those providers can observe the login token and connection metadata. The last successful quota snapshot for each source is stored in this Mac's `ai-usage.sqlite` database with percentages, reset times, source, and fetch time, but no login token. Failed queries may show that snapshot marked as old data. Authentication failure or removal of a manual configuration deletes that source's snapshot. Disabling the feature stops queries and hides quotas but retains the local snapshot for re-enabling. Quota results are not uploaded to XStats servers.
|
||||
- With local AI usage enabled and its view visible, cost estimates fetch public base model prices from `models.dev/api.json` on demand. Selecting CNY also fetches a reference exchange rate from `open.er-api.com/v6/latest/USD`. These requests send no session logs, token counts, model usage records, or login credentials; the providers can observe connection metadata. Prices and exchange rates are cached locally and refreshed on demand after 24 and 12 hours respectively. Failed refreshes may keep older cached values with their fetch times. Closing the view, disabling the feature, or system suspension cancels requests that are no longer needed. Estimates use local token statistics, exclude tiered surcharges, and are not subscription bills.
|
||||
- With a Codex or Claude Code Sub2API backup configured, a failed automatic lookup for that source causes a direct request to its chosen Sub2API server. That server receives the admin email, password, login token, and account ID being queried. XStats provides no preset Sub2API server and does not upload these values or quota results to XStats servers.
|
||||
- When you open network details with public-IP lookup enabled, XStats contacts Cloudflare `1.1.1.1`, falling back to ipify. It sends your public IP address to `cleanip.io` for location, ASN, network type, and cleanliness information. Results are cached locally.
|
||||
- Connection probing sends ICMP packets to your selected target when enabled. Speed tests, egress checks, and DNS lookups contact selected test nodes, websites, or DNS services only when you use those features. Those services can observe connection metadata and the data needed for the test.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# XStats 隐私政策
|
||||
|
||||
生效日期:2026 年 10 月 2 日
|
||||
生效日期:2026 年 10 月 3 日
|
||||
|
||||
本政策说明 XStats 在 macOS 上处理哪些信息,以及哪些功能会连接外部服务。XStats 自有服务仅保存第 2 节所述的更新统计汇总;应用自身的其他持久化数据保存在本机,不上传到 XStats 自有服务器。联网测试会向相应第三方发送完成测试所需的请求,详见第 3 节。联系邮箱:i@xiai.me。
|
||||
|
||||
@@ -26,6 +26,7 @@ CPU、GPU、内存、网络、温度、风扇、进程等监控数据来自本
|
||||
## 3. 其他联网功能
|
||||
|
||||
- 启用“AI 用量”后,Codex 优先通过本机已安装的 Codex CLI App Server 查询额度;不可用时使用本机 Codex 登录凭据直连 `chatgpt.com`。Claude Code 使用本机登录凭据直连 `api.anthropic.com`。相应服务可以看到登录令牌及连接元数据。每个来源最近一次成功的额度快照保存在本机 `ai-usage.sqlite`,包含百分比、重置时间、数据来源及获取时间,不含登录令牌;查询失败时可显示并标记为旧数据。认证失效或移除手动配置会删除对应快照。关闭功能后停止查询并隐藏额度,本地快照仍保留以便重新启用;额度结果不上传到 XStats 服务器。
|
||||
- 启用本地 AI 用量且用量界面可见时,费用估算按需读取 `models.dev/api.json` 的公开模型基础单价;选择人民币时另读取 `open.er-api.com/v6/latest/USD` 的参考汇率。请求不发送会话日志、Token 数量、模型使用记录或登录凭据,第三方可看到连接元数据。单价与汇率分别在本机缓存 24 小时和 12 小时后按需更新,失败时可继续显示带更新时间的旧缓存。关闭界面、关闭功能或系统暂停时取消不再需要的请求。费用根据本机 Token 统计计算,不含阶梯加价,不代表订阅账单。
|
||||
- 配置 Codex 或 Claude Code 的 Sub2API 备选来源后,对应的自动额度查询失败时,应用直接请求你为该来源指定的 Sub2API 服务器;该服务器会收到管理员邮箱、密码、登录令牌和查询账号 ID。XStats 不预设 Sub2API 服务器,也不向 XStats 服务器上传这些信息或额度结果。
|
||||
- 打开网络详情且启用公网 IP 查询时,应用直接访问 Cloudflare `1.1.1.1`,失败时回退到 ipify;归属地、ASN、网络类型和纯净度查询会把公网 IP 发送给 `cleanip.io`。结果在本机缓存。
|
||||
- 启用连接探测时,应用向所选目标发送 ICMP 探测。测速、出口检测和 DNS 查询仅在你使用相应功能时,直接连接所选测试节点、网站或 DNS 服务;对方可看到连接元数据及测试所需内容。
|
||||
|
||||
@@ -35,6 +35,11 @@ public final class AIUsageController {
|
||||
public private(set) var quotaStates: [AIProviderID: AIQuotaProviderState]
|
||||
/// 供 AppController 观察的刷新脉冲;各额度来源另有自己的 lastAttemptAt。
|
||||
public private(set) var lastAttemptAt: Date?
|
||||
public private(set) var costReferences = AICostReferenceSnapshot(catalog: nil, exchangeRate: nil)
|
||||
private var costRefreshCount = 0
|
||||
public var isRefreshingCostReferences: Bool { costRefreshCount > 0 }
|
||||
@ObservationIgnored private var costTasks: [UUID: Task<AICostReferenceSnapshot, Never>] = [:]
|
||||
@ObservationIgnored private let costReferenceStore: AICostReferenceStore?
|
||||
|
||||
@ObservationIgnored private let settings: AppSettings
|
||||
@ObservationIgnored private let providers: [any AIUsageProvider]
|
||||
@@ -48,9 +53,10 @@ public final class AIUsageController {
|
||||
|
||||
public init(settings: AppSettings, providers: [any AIUsageProvider] = [CodexLocalUsageProvider(), ClaudeLocalUsageProvider()],
|
||||
quotaProviders: [any AIQuotaProvider] = [],
|
||||
quotaCacheURL: URL? = nil,
|
||||
quotaCacheURL: URL? = nil, costReferenceStore: AICostReferenceStore? = nil,
|
||||
now: @escaping @Sendable () -> Date = Date.init) {
|
||||
self.settings = settings
|
||||
self.costReferenceStore = costReferenceStore
|
||||
self.providers = providers
|
||||
self.quotaProviders = quotaProviders
|
||||
quotaCache = quotaCacheURL.flatMap { try? AIQuotaCacheStore(url: $0) }
|
||||
@@ -60,6 +66,38 @@ public final class AIUsageController {
|
||||
restoreCachedQuotas()
|
||||
}
|
||||
|
||||
/// 由可见的用量页请求;复用价格/汇率缓存,取消随视图任务传播,不增加轮询。
|
||||
public func refreshCostReferences() async {
|
||||
guard let costReferenceStore, settings.aiUsageEnabled, settings.aiUsageShowsLocalUsage, !paused,
|
||||
localReport(for: nil)?.rows.contains(where: { $0.input > 0 || $0.output > 0 }) == true else { return }
|
||||
let epoch = generation
|
||||
let id = UUID()
|
||||
let needsExchangeRate = settings.aiUsageCurrency == .cny
|
||||
let task = Task { await costReferenceStore.refresh(needsExchangeRate: needsExchangeRate) }
|
||||
costTasks[id] = task
|
||||
costRefreshCount += 1
|
||||
defer { costTasks[id] = nil; costRefreshCount -= 1 }
|
||||
// 视图消失与系统暂停都必须取消实际请求,不能只丢弃最终结果。
|
||||
let incoming = await withTaskCancellationHandler {
|
||||
await task.value
|
||||
} onCancel: {
|
||||
task.cancel()
|
||||
}
|
||||
guard !Task.isCancelled, generation == epoch, settings.aiUsageEnabled,
|
||||
settings.aiUsageShowsLocalUsage, !paused else { return }
|
||||
// 多个可见窗口可能并发读取;较早返回的美元请求不能清掉新汇率。
|
||||
let catalog = [costReferences.catalog, incoming.catalog].compactMap { $0 }.max { $0.fetchedAt < $1.fetchedAt }
|
||||
let rate = [costReferences.exchangeRate, incoming.exchangeRate].compactMap { $0 }.max { $0.fetchedAt < $1.fetchedAt }
|
||||
let merged = AICostReferenceSnapshot(catalog: catalog, exchangeRate: rate)
|
||||
if merged != costReferences { costReferences = merged }
|
||||
}
|
||||
|
||||
public func estimatedCost(_ rows: [ModelTokenUsage]) -> AICostEstimate {
|
||||
if rows.allSatisfy({ $0.input == 0 && $0.output == 0 && $0.cached == 0 && $0.cacheCreated == 0 }) { return AICostEstimate(usd: 0, unpricedModels: []) }
|
||||
return costReferences.catalog?.estimate(rows)
|
||||
?? AICostEstimate(usd: nil, unpricedModels: Array(Set(rows.filter { $0.input != 0 || $0.output != 0 || $0.cached != 0 || $0.cacheCreated != 0 }.map(\.model))).sorted())
|
||||
}
|
||||
|
||||
private func restoreCachedQuotas() {
|
||||
guard let cached = try? quotaCache?.load() else { return }
|
||||
for provider in quotaProviders where settings.aiUsageSources.contains(provider.id) {
|
||||
@@ -140,6 +178,7 @@ public final class AIUsageController {
|
||||
cadenceTask?.cancel()
|
||||
cadenceTask = nil
|
||||
if !settings.aiUsageShowsLocalUsage {
|
||||
for task in costTasks.values { task.cancel() }
|
||||
// 关闭日志读取时丢弃在途扫描;额度轮询会在下方重新启动。
|
||||
generation += 1
|
||||
refreshTask?.cancel()
|
||||
@@ -152,6 +191,7 @@ public final class AIUsageController {
|
||||
quotaStates[provider.id] = AIQuotaProviderState(provider: provider.id)
|
||||
}
|
||||
guard settings.aiUsageEnabled, !settings.aiUsageSources.isEmpty, !paused else {
|
||||
for task in costTasks.values { task.cancel() }
|
||||
generation += 1
|
||||
// 不再轮询时,在途的冷扫描也没有意义了
|
||||
refreshTask?.cancel(); refreshTask = nil
|
||||
@@ -175,6 +215,7 @@ public final class AIUsageController {
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
for task in costTasks.values { task.cancel() }
|
||||
generation += 1
|
||||
cadenceTask?.cancel()
|
||||
cadenceTask = nil
|
||||
|
||||
@@ -86,7 +86,7 @@ public final class AppModel {
|
||||
self.settings = settings
|
||||
rest = RestController(settings: settings)
|
||||
aiUsage = AIUsageController(settings: settings, providers: aiUsageProviders, quotaProviders: aiQuotaProviders,
|
||||
quotaCacheURL: quotaCacheURL)
|
||||
quotaCacheURL: quotaCacheURL, costReferenceStore: .shared)
|
||||
self.store = store
|
||||
self.helper = helper
|
||||
fans = FanController(helper: helper, store: store, settings: settings)
|
||||
|
||||
@@ -572,6 +572,15 @@ public final class AppSettings {
|
||||
public var aiUsageShowsLocalUsage: Bool {
|
||||
didSet { defaults.set(aiUsageShowsLocalUsage, forKey: Keys.aiUsageShowsLocalUsage) }
|
||||
}
|
||||
public var aiQuotaShowsRemaining: Bool {
|
||||
didSet { defaults.set(aiQuotaShowsRemaining, forKey: Keys.aiQuotaShowsRemaining) }
|
||||
}
|
||||
public var aiUsageWesternUnits: Bool {
|
||||
didSet { defaults.set(aiUsageWesternUnits, forKey: Keys.aiUsageWesternUnits) }
|
||||
}
|
||||
public var aiUsageCurrency: AIUsageCurrency {
|
||||
didSet { defaults.set(aiUsageCurrency.rawValue, forKey: Keys.aiUsageCurrency) }
|
||||
}
|
||||
public var aiUsageRefreshMinutes: Int {
|
||||
didSet { defaults.set(aiUsageRefreshMinutes, forKey: Keys.aiUsageRefreshMinutes) }
|
||||
}
|
||||
@@ -726,7 +735,7 @@ public final class AppSettings {
|
||||
public static let restBreakOptions = [3, 5, 10]
|
||||
public static let restLongBreakOptions = [10, 15, 20, 30]
|
||||
public static let restDailyGoalOptions = [4, 6, 8, 12]
|
||||
public static let aiUsageRefreshOptions = [5, 15, 30, 60]
|
||||
public static let aiUsageRefreshOptions = [1, 3, 5, 10, 15, 30, 60]
|
||||
public static let batteryFloorOptions = [10, 20, 30, 40]
|
||||
public static let fanSafetyOptions = [85, 90, 95, 100]
|
||||
public static let alertTemperatureOptions = [85, 90, 95, 100]
|
||||
@@ -786,6 +795,9 @@ public final class AppSettings {
|
||||
aiUsageSources = defaults.stringArray(forKey: Keys.aiUsageSources)
|
||||
.map { Set($0.compactMap(AIProviderID.init(rawValue:))) } ?? Set(AIProviderID.allCases)
|
||||
aiUsageShowsLocalUsage = defaults.object(forKey: Keys.aiUsageShowsLocalUsage) as? Bool ?? true
|
||||
aiQuotaShowsRemaining = defaults.object(forKey: Keys.aiQuotaShowsRemaining) as? Bool ?? true
|
||||
aiUsageWesternUnits = defaults.bool(forKey: Keys.aiUsageWesternUnits)
|
||||
aiUsageCurrency = defaults.string(forKey: Keys.aiUsageCurrency).flatMap(AIUsageCurrency.init(rawValue:)) ?? .usd
|
||||
aiUsageRefreshMinutes = Self.aiUsageRefreshOptions.contains(defaults.integer(forKey: Keys.aiUsageRefreshMinutes))
|
||||
? defaults.integer(forKey: Keys.aiUsageRefreshMinutes) : 30
|
||||
colorizeHighLoad = defaults.bool(forKey: Keys.colorizeHighLoad)
|
||||
@@ -912,6 +924,9 @@ public final class AppSettings {
|
||||
static let processesEnabled = "processesEnabled"
|
||||
static let aiUsageSources = "aiUsageSources"
|
||||
static let aiUsageShowsLocalUsage = "aiUsageShowsLocalUsage"
|
||||
static let aiQuotaShowsRemaining = "aiQuotaShowsRemaining"
|
||||
static let aiUsageWesternUnits = "aiUsageWesternUnits"
|
||||
static let aiUsageCurrency = "aiUsageCurrency"
|
||||
static let aiUsageRefreshMinutes = "aiUsageRefreshMinutes"
|
||||
static let colorizeHighLoad = "colorizeHighLoad"
|
||||
static let bluetoothLowBatteryInMenuBar = "bluetoothLowBatteryInMenuBar"
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
// Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
import Testing
|
||||
@testable import AIUsage
|
||||
|
||||
@Suite struct AICostReferenceTests {
|
||||
private static let date = Date(timeIntervalSince1970: 1_780_000_000)
|
||||
private static let prices = Data(#"{"openai":{"models":{"gpt-test":{"cost":{"input":2,"output":8,"cache_read":0.5}}}},"anthropic":{"models":{"claude-test":{"cost":{"input":3,"output":15,"cache_read":0.3,"cache_write":3.75}}}}}"#.utf8)
|
||||
private static let exchange = Data(#"{"result":"success","base_code":"USD","rates":{"CNY":7.25}}"#.utf8)
|
||||
|
||||
private func row(_ model: String = "claude-test", input: Int = 0, cached: Int = 0,
|
||||
output: Int = 0, created: Int = 0) -> ModelTokenUsage {
|
||||
ModelTokenUsage(day: Self.date, model: model, input: input, cached: cached, output: output, cacheCreated: created)
|
||||
}
|
||||
|
||||
@Test func cacheTokensAreChargedExactlyOnce() throws {
|
||||
let catalog = try AIPriceCatalog.parse(Self.prices, fetchedAt: Self.date)
|
||||
let result = catalog.estimate([row(input: 1_000_000, cached: 200_000, output: 100_000, created: 100_000)])
|
||||
let usd = try #require(result.usd)
|
||||
// 700k 普通输入 + 200k 读取 + 100k 创建 + 100k 输出。
|
||||
#expect(abs(usd - 4.035) < 0.0000001)
|
||||
#expect(result.unpricedModels.isEmpty)
|
||||
}
|
||||
|
||||
@Test func missingUsedCategoryMakesRowUnknownButExplicitZeroIsFree() throws {
|
||||
let catalog = AIPriceCatalog(prices: [
|
||||
"missing": AIModelPrice(input: 1, output: 2),
|
||||
"free": AIModelPrice(input: 0, output: 0, cacheRead: 0, cacheWrite: 0)
|
||||
], fetchedAt: Self.date)
|
||||
#expect(catalog.estimate([row("missing", input: 10, cached: 1)]).usd == nil)
|
||||
#expect(catalog.estimate([row("missing", input: 10, created: 1)]).usd == nil)
|
||||
#expect(catalog.estimate([row("missing", input: 10)]).usd == 0.00001)
|
||||
#expect(catalog.estimate([row("free", input: 10, cached: 3, output: 4, created: 2)]).usd == 0)
|
||||
}
|
||||
|
||||
@Test func unknownModelsRemainVisibleAndOnlyExplicitPrefixesMatch() throws {
|
||||
let catalog = try AIPriceCatalog.parse(Self.prices, fetchedAt: Self.date)
|
||||
let result = catalog.estimate([
|
||||
row("openai/gpt-test", input: 1_000_000), row("anthropic/claude-test", output: 1_000_000),
|
||||
row("gpt-test-new", input: 2), row("other/gpt-test", input: 2),
|
||||
row("openai/claude-test", input: 2), row("gpt-test-new", input: 2),
|
||||
row("claude-test-20991231", input: 2)
|
||||
])
|
||||
#expect(result.usd == 17)
|
||||
#expect(result.unpricedModels == ["claude-test-20991231", "gpt-test-new", "openai/claude-test", "other/gpt-test"])
|
||||
#expect(catalog.estimate([row("unknown", input: 1)]).usd == nil)
|
||||
#expect(catalog.estimate([]).usd == 0)
|
||||
#expect(catalog.estimate([row("unknown")]).usd == 0)
|
||||
}
|
||||
|
||||
@Test func invalidCountsAndOverflowNeverProduceNonfiniteCost() {
|
||||
let catalog = AIPriceCatalog(prices: ["a": AIModelPrice(input: .greatestFiniteMagnitude, output: 1)], fetchedAt: Self.date)
|
||||
for value in [row("a", input: -1), row("a", input: 1, cached: 2), row("a", input: 2, cached: 1, created: 2),
|
||||
row("a", input: .max), row("a", input: 1, cached: .max, created: .max)] {
|
||||
#expect(catalog.estimate([value]).usd == nil)
|
||||
}
|
||||
let large = catalog.estimate([row("a", input: 1_000_000), row("a", input: 1_000_000)])
|
||||
#expect(large.usd?.isFinite == true)
|
||||
#expect(large.unpricedModels == ["a"])
|
||||
for value in [Double.infinity, .nan, -1] {
|
||||
let invalid = AIPriceCatalog(prices: ["a": AIModelPrice(input: value, output: 1)], fetchedAt: Self.date)
|
||||
#expect(invalid.estimate([row("a", input: 1)]).usd == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func parsingRejectsMalformedPricesAndIgnoresOtherProviders() throws {
|
||||
let data = Data(#"{"openai":{"models":{"missing":{"cost":{"input":1}},"negative":{"cost":{"input":-1,"output":1}},"boolean":{"cost":{"input":true,"output":2}},"string":{"cost":{"input":"2","output":2}},"free":{"cost":{"input":0,"output":0}},"bad-cache":{"cost":{"input":1,"output":1,"cache_read":false,"cache_write":-2}}}},"reseller":{"models":{"private":{"cost":{"input":1,"output":1}}}}}"#.utf8)
|
||||
let result = try AIPriceCatalog.parse(data, fetchedAt: Self.date)
|
||||
#expect(Set(result.prices.keys) == ["free", "bad-cache"])
|
||||
#expect(result.prices["bad-cache"]?.cacheRead == nil)
|
||||
#expect(result.prices["bad-cache"]?.cacheWrite == nil)
|
||||
#expect(result.estimate([row("bad-cache", input: 1, cached: 1)]).usd == nil)
|
||||
#expect(throws: AICostReferenceError.invalidResponse) {
|
||||
try AIPriceCatalog.parse(Data(#"{"openai":{"models":{}}}"#.utf8), fetchedAt: Self.date)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func exchangeRateRequiresUSDAndPositiveNumericCNY() throws {
|
||||
#expect(try AIExchangeRate.parse(Self.exchange, fetchedAt: Self.date).cnyPerUSD == 7.25)
|
||||
for json in [#"{"result":"error","base_code":"USD","rates":{"CNY":7}}"#,
|
||||
#"{"result":"success","base_code":"EUR","rates":{"CNY":7}}"#,
|
||||
#"{"result":"success","base_code":"USD","rates":{"CNY":0}}"#,
|
||||
#"{"result":"success","base_code":"USD","rates":{"CNY":-1}}"#,
|
||||
#"{"result":"success","base_code":"USD","rates":{"CNY":true}}"#,
|
||||
#"{"result":"success","base_code":"USD","rates":{"CNY":"7"}}"#,
|
||||
#"{"result":"success","base_code":"USD","rates":{}}"#] {
|
||||
#expect(throws: AICostReferenceError.invalidResponse) {
|
||||
try AIExchangeRate.parse(Data(json.utf8), fetchedAt: Self.date)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func limitsRejectOversizedInjectedResponses() async {
|
||||
let store = AICostReferenceStore(cacheDirectory: nil, fetch: { _, limit in Data(repeating: 32, count: limit + 1) })
|
||||
let result = await store.refresh(needsExchangeRate: true)
|
||||
#expect(result.catalog == nil)
|
||||
#expect(result.exchangeRate == nil)
|
||||
}
|
||||
|
||||
@Test func refreshHonorsIndependentTTLsAndOnDemandExchange() async {
|
||||
let clock = OSAllocatedUnfairLock(initialState: Self.date)
|
||||
let server = ReferenceServer()
|
||||
let store = AICostReferenceStore(cacheDirectory: nil, now: { clock.withLock { $0 } }, fetch: server.fetch)
|
||||
#expect(await store.refresh(needsExchangeRate: false).catalog != nil)
|
||||
#expect(await server.counts == [1, 0])
|
||||
#expect(await store.refresh(needsExchangeRate: true).exchangeRate != nil)
|
||||
#expect(await server.counts == [1, 1])
|
||||
clock.withLock { $0 = $0.addingTimeInterval(43_200) }
|
||||
_ = await store.refresh(needsExchangeRate: false)
|
||||
#expect(await server.counts == [1, 1])
|
||||
_ = await store.refresh(needsExchangeRate: true)
|
||||
#expect(await server.counts == [1, 2])
|
||||
clock.withLock { $0 = $0.addingTimeInterval(43_200) }
|
||||
_ = await store.refresh(needsExchangeRate: true)
|
||||
#expect(await server.counts == [2, 3])
|
||||
}
|
||||
|
||||
@Test func failureKeepsOldCacheAndBacksOffTenMinutes() async throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let clock = OSAllocatedUnfairLock(initialState: Self.date)
|
||||
let server = ReferenceServer()
|
||||
let store = AICostReferenceStore(cacheDirectory: root, now: { clock.withLock { $0 } }, fetch: server.fetch)
|
||||
let original = await store.refresh(needsExchangeRate: true)
|
||||
clock.withLock { $0 = $0.addingTimeInterval(86_400) }
|
||||
await server.setFailure(true)
|
||||
#expect(await store.refresh(needsExchangeRate: true) == original)
|
||||
#expect(await server.counts == [2, 2])
|
||||
clock.withLock { $0 = $0.addingTimeInterval(599) }
|
||||
#expect(await store.refresh(needsExchangeRate: true) == original)
|
||||
#expect(await server.counts == [2, 2])
|
||||
clock.withLock { $0 = $0.addingTimeInterval(1) }
|
||||
#expect(await store.refresh(needsExchangeRate: true) == original)
|
||||
#expect(await server.counts == [3, 3])
|
||||
let restarted = AICostReferenceStore(cacheDirectory: root, now: { clock.withLock { $0 } }, fetch: server.fetch)
|
||||
#expect(await restarted.refresh(needsExchangeRate: true) == original)
|
||||
let files = try FileManager.default.contentsOfDirectory(atPath: root.path).sorted()
|
||||
#expect(files == ["exchange-v1.json", "prices-v1.json"])
|
||||
#expect(try Data(contentsOf: root.appendingPathComponent("prices-v1.json")).count < 10_000)
|
||||
}
|
||||
|
||||
@Test func freshDiskCacheAvoidsNetworkAndInvalidCacheIsIgnored() async throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let server = ReferenceServer()
|
||||
let first = AICostReferenceStore(cacheDirectory: root, now: { Self.date }, fetch: server.fetch)
|
||||
let original = await first.refresh(needsExchangeRate: true)
|
||||
let second = AICostReferenceStore(cacheDirectory: root, now: { Self.date }, fetch: server.fetch)
|
||||
#expect(await second.refresh(needsExchangeRate: true) == original)
|
||||
#expect(await server.counts == [1, 1])
|
||||
try Data(repeating: 32, count: 1024 * 1024 + 1).write(to: root.appendingPathComponent("prices-v1.json"))
|
||||
let invalid = AIExchangeRate(cnyPerUSD: -1, fetchedAt: Self.date)
|
||||
try JSONEncoder().encode(invalid).write(to: root.appendingPathComponent("exchange-v1.json"))
|
||||
await server.setFailure(true)
|
||||
let third = AICostReferenceStore(cacheDirectory: root, now: { Self.date }, fetch: server.fetch)
|
||||
let result = await third.refresh(needsExchangeRate: true)
|
||||
#expect(result.catalog == nil)
|
||||
#expect(result.exchangeRate == nil)
|
||||
}
|
||||
|
||||
@Test func coldFailureReturnsNilAndRetriesOnlyAfterBackoff() async {
|
||||
let server = ReferenceServer()
|
||||
await server.setFailure(true)
|
||||
let store = AICostReferenceStore(cacheDirectory: nil, now: { Self.date }, fetch: server.fetch)
|
||||
let first = await store.refresh(needsExchangeRate: true)
|
||||
#expect(first.catalog == nil && first.exchangeRate == nil)
|
||||
_ = await store.refresh(needsExchangeRate: true)
|
||||
#expect(await server.counts == [1, 1])
|
||||
}
|
||||
|
||||
@Test func concurrentRequestsMergeAndLateExchangeDemandIsFulfilled() async {
|
||||
let server = ReferenceServer(suspended: true)
|
||||
let store = AICostReferenceStore(cacheDirectory: nil, now: { Self.date }, fetch: server.fetch)
|
||||
let dollars = Task { await store.refresh(needsExchangeRate: false) }
|
||||
await server.waitForRequest()
|
||||
let yuan = Task { await store.refresh(needsExchangeRate: true) }
|
||||
let moreDollars = Task { await store.refresh(needsExchangeRate: false) }
|
||||
await server.release()
|
||||
#expect(await dollars.value.catalog != nil)
|
||||
#expect(await moreDollars.value.catalog != nil)
|
||||
#expect(await yuan.value.exchangeRate != nil)
|
||||
#expect(await server.counts == [1, 1])
|
||||
}
|
||||
|
||||
@Test func cancellationDoesNotPublishOrPersistAndNextRefreshCanRetry() async throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let server = ReferenceServer(suspended: true)
|
||||
let store = AICostReferenceStore(cacheDirectory: root, now: { Self.date }, fetch: server.fetch)
|
||||
let task = Task { await store.refresh(needsExchangeRate: true) }
|
||||
await server.waitForRequest()
|
||||
task.cancel()
|
||||
await server.release()
|
||||
let canceled = await task.value
|
||||
#expect(canceled.catalog == nil && canceled.exchangeRate == nil)
|
||||
#expect(!FileManager.default.fileExists(atPath: root.path))
|
||||
#expect(await server.counts == [1, 0])
|
||||
let next = await store.refresh(needsExchangeRate: true)
|
||||
#expect(next.catalog != nil && next.exchangeRate != nil)
|
||||
#expect(await server.counts == [2, 1])
|
||||
}
|
||||
|
||||
private actor ReferenceServer {
|
||||
private var fail = false
|
||||
private var suspended: Bool
|
||||
private var pending: [CheckedContinuation<Void, Never>] = []
|
||||
private var observers: [CheckedContinuation<Void, Never>] = []
|
||||
private(set) var counts = [0, 0]
|
||||
init(suspended: Bool = false) { self.suspended = suspended }
|
||||
func setFailure(_ value: Bool) { fail = value }
|
||||
func waitForRequest() async {
|
||||
if counts.reduce(0, +) > 0 { return }
|
||||
await withCheckedContinuation { observers.append($0) }
|
||||
}
|
||||
func release() {
|
||||
suspended = false
|
||||
let waiting = pending; pending.removeAll()
|
||||
waiting.forEach { $0.resume() }
|
||||
}
|
||||
func fetch(_ url: URL, limit: Int) async throws -> Data {
|
||||
let isCatalog = url.host == "models.dev"
|
||||
#expect(url.absoluteString == (isCatalog ? "https://models.dev/api.json" : "https://open.er-api.com/v6/latest/USD"))
|
||||
#expect(limit == (isCatalog ? 16 * 1024 * 1024 : 1024 * 1024))
|
||||
counts[isCatalog ? 0 : 1] += 1
|
||||
let waiting = observers; observers.removeAll()
|
||||
waiting.forEach { $0.resume() }
|
||||
if suspended { await withCheckedContinuation { pending.append($0) } }
|
||||
if fail { throw URLError(.notConnectedToInternet) }
|
||||
return isCatalog ? AICostReferenceTests.prices : AICostReferenceTests.exchange
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import Testing
|
||||
@testable import AIUsage
|
||||
|
||||
@Suite struct UsageActivityTests {
|
||||
@Test func summaryFollowsTodayThisWeekAndThisMonthWhileActivityStaysAnnual() {
|
||||
@Test func summaryFollowsTodayThisWeekAndAnnualCumulative() {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
|
||||
calendar.firstWeekday = 2
|
||||
@@ -20,10 +20,34 @@ import Testing
|
||||
row("2026-09-23", 99999), row("2026-09-22", 2, model: "b")], fileCount: 1)
|
||||
#expect(LocalUsageReport.total(report.summary(mode: .daily, now: now, calendar: calendar)).total == 3)
|
||||
#expect(LocalUsageReport.total(report.summary(mode: .weekly, now: now, calendar: calendar)).total == 13)
|
||||
#expect(LocalUsageReport.total(report.summary(mode: .cumulative, now: now, calendar: calendar)).total == 1113)
|
||||
#expect(LocalUsageReport.total(report.summary(mode: .cumulative, model: "a", now: now, calendar: calendar)).total == 1111)
|
||||
#expect(LocalUsageReport.total(report.summary(mode: .cumulative, now: now, calendar: calendar)).total == 11113)
|
||||
#expect(LocalUsageReport.total(report.summary(mode: .cumulative, model: "a", now: now, calendar: calendar)).total == 11111)
|
||||
#expect(LocalUsageReport.total(report.selected(days: 365, now: now, calendar: calendar)).total == 11113)
|
||||
}
|
||||
@Test(arguments: ["2026-01-01T12:00:00Z", "2026-03-01T12:00:00Z"])
|
||||
func cumulativeSummaryMatchesChartAcrossYearAndMonthBoundaries(_ timestamp: String) throws {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = try #require(TimeZone(identifier: "Asia/Shanghai"))
|
||||
let now = try #require(ISO8601DateFormatter().date(from: timestamp))
|
||||
let today = calendar.startOfDay(for: now)
|
||||
let rows = [-1, 0, 31, 364, 365].flatMap { ago in
|
||||
["a", "b"].map { model in
|
||||
ModelTokenUsage(day: calendar.date(byAdding: .day, value: -ago, to: today)!,
|
||||
model: model, input: model == "a" ? 100 : 200, output: 10)
|
||||
}
|
||||
}
|
||||
let report = LocalUsageReport(rows: rows, fileCount: 1)
|
||||
for model: String? in [nil, "a", "b"] {
|
||||
let summary = report.summary(mode: .cumulative, model: model, now: now, calendar: calendar)
|
||||
let activity = UsageActivity(rows: report.selected(days: 365, model: model, now: now, calendar: calendar),
|
||||
now: now, calendar: calendar)
|
||||
let total = LocalUsageReport.total(summary).total
|
||||
#expect(total == activity.peak(for: .cumulative))
|
||||
#expect(total == (model == nil ? 960 : model == "a" ? 330 : 630))
|
||||
#expect(LocalUsageReport.models(summary).reduce(0) { $0 + $1.total } == total)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func weeklyAndCumulativeColorsDistinguishUsageWithoutLightingZero() {
|
||||
#expect(UsageActivity.colorIntensity(value: 0, peak: 100, mode: .weekly) == 0)
|
||||
#expect(UsageActivity.colorIntensity(value: 100, peak: 100, mode: .cumulative) == 1)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
import Localization
|
||||
|
||||
struct AIUsageFormatTests {
|
||||
@Test(arguments: [
|
||||
(0, "0"), (999, "999"), (1_000, "1K"), (1_234, "1.2K"),
|
||||
(100_000, "100K"), (123_456, "123K"), (999_499, "999K"),
|
||||
(999_500, "1M"), (1_000_000, "1M"), (1_234_567, "1.2M"),
|
||||
(10_000_000, "10M"), (12_345_678, "12M"), (999_499_999, "999M"),
|
||||
(999_500_000, "1B"), (1_000_000_000, "1B"), (1_234_567_890, "1.23B"),
|
||||
(-1_234, "-1.2K")
|
||||
])
|
||||
func westernTokens(_ value: Int, _ expected: String) {
|
||||
#expect(AIUsageFormat.tokens(value, locale: Locale(identifier: "en_US")) == expected)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
(9_999, "9999"), (10_000, "1万"), (12_345, "1.2万"),
|
||||
(99_999_499, "9999.9万"), (99_999_500, "1亿"),
|
||||
(100_000_000, "1亿"), (123_456_789, "1.23亿")
|
||||
])
|
||||
func simplifiedChineseTokens(_ value: Int, _ expected: String) {
|
||||
#expect(AIUsageFormat.tokens(value, locale: Locale(identifier: "zh_CN")) == expected)
|
||||
}
|
||||
|
||||
@Test func tokenLocalesAndWesternOverride() {
|
||||
#expect(AIUsageFormat.tokens(12_345, locale: Locale(identifier: "zh_Hant_TW")) == "1.2萬")
|
||||
#expect(AIUsageFormat.tokens(123_456_789, locale: Locale(identifier: "zh_TW")) == "1.23億")
|
||||
#expect(AIUsageFormat.tokens(12_345, westernUnits: true, locale: Locale(identifier: "zh_CN")) == "12.3K")
|
||||
#expect(AIUsageFormat.tokens(1_234, locale: Locale(identifier: "de_DE")) == "1,2K")
|
||||
#expect(AIUsageFormat.tokens(1_234, locale: Locale(identifier: "ar_SA")) == "١٫٢K")
|
||||
#expect(!AIUsageFormat.tokens(Int.min, locale: Locale(identifier: "en_US")).isEmpty)
|
||||
#expect(!AIUsageFormat.tokens(Int.max, locale: Locale(identifier: "zh_CN")).isEmpty)
|
||||
}
|
||||
|
||||
@Test func quotaClampsAndInverts() {
|
||||
#expect(AIUsageFormat.quotaValue(remainingPercent: -10, showsRemaining: true) == 0)
|
||||
#expect(AIUsageFormat.quotaValue(remainingPercent: 110, showsRemaining: true) == 100)
|
||||
#expect(AIUsageFormat.quotaValue(remainingPercent: -10, showsRemaining: false) == 100)
|
||||
#expect(AIUsageFormat.quotaValue(remainingPercent: 110, showsRemaining: false) == 0)
|
||||
#expect(AIUsageFormat.quotaValue(remainingPercent: 75, showsRemaining: false) == 25)
|
||||
}
|
||||
|
||||
@Test(arguments: [Double.nan, Double.infinity, -Double.infinity])
|
||||
func unknownQuotaIsNotZero(_ value: Double) {
|
||||
#expect(AIUsageFormat.quotaValue(remainingPercent: value, showsRemaining: true) == nil)
|
||||
#expect(AIUsageFormat.quotaValue(remainingPercent: value, showsRemaining: false) == nil)
|
||||
#expect(AIUsageFormat.quotaPercent(remainingPercent: value, showsRemaining: true,
|
||||
locale: Locale(identifier: "en_US")) == "—")
|
||||
}
|
||||
|
||||
@Test func compactQuotaPreservesEndpoints() {
|
||||
let locale = Locale(identifier: "en_US")
|
||||
#expect(AIUsageFormat.quotaPercent(remainingPercent: 0, showsRemaining: true, locale: locale, compact: true) == "0%")
|
||||
#expect(AIUsageFormat.quotaPercent(remainingPercent: 100, showsRemaining: true, locale: locale, compact: true) == "100%")
|
||||
#expect(AIUsageFormat.quotaPercent(remainingPercent: 0.4, showsRemaining: true, locale: locale, compact: true) == "<1%")
|
||||
#expect(AIUsageFormat.quotaPercent(remainingPercent: 99.6, showsRemaining: true, locale: locale, compact: true) == ">99%")
|
||||
#expect(AIUsageFormat.quotaPercent(remainingPercent: 99.6, showsRemaining: false, locale: locale, compact: true) == "<1%")
|
||||
#expect(AIUsageFormat.quotaPercent(remainingPercent: 0, showsRemaining: false, locale: locale, compact: true) == "100%")
|
||||
#expect(AIUsageFormat.quotaPercent(remainingPercent: 99.6, showsRemaining: true, locale: locale) == "99.6%")
|
||||
#expect(AIUsageFormat.quotaPercent(remainingPercent: 99.6, showsRemaining: false, locale: locale) == "0.4%")
|
||||
}
|
||||
|
||||
@Test func percentUsesExplicitLocale() {
|
||||
let arabic = AIUsageFormat.quotaPercent(remainingPercent: 12.3, showsRemaining: true, locale: Locale(identifier: "ar_SA"))
|
||||
#expect(arabic.contains("١٢٫٣"))
|
||||
#expect(arabic.contains("٪"))
|
||||
let german = AIUsageFormat.quotaPercent(remainingPercent: 12.3, showsRemaining: true, locale: Locale(identifier: "de_DE"))
|
||||
#expect(german.contains("12,3"))
|
||||
}
|
||||
|
||||
@Test(arguments: [(0.0, "$0"), (0.0123, "$0.012"), (1.234, "$1.23"), (100.49, "$100")])
|
||||
func dollarsUseMagnitudePrecision(_ value: Double, _ expected: String) {
|
||||
#expect(AIUsageFormat.money(usd: value, currency: .usd, usdToCNY: nil,
|
||||
locale: Locale(identifier: "en_US")) == expected)
|
||||
}
|
||||
|
||||
@Test func yuanRequiresExchangeRateAndConvertsForDisplay() {
|
||||
let usd = 2.0
|
||||
let locale = Locale(identifier: "zh_CN")
|
||||
#expect(AIUsageFormat.money(usd: usd, currency: .cny, usdToCNY: nil, locale: locale) == nil)
|
||||
#expect(AIUsageFormat.money(usd: usd, currency: .cny, usdToCNY: 7.25, locale: locale) == "¥14.5")
|
||||
#expect(usd == 2)
|
||||
#expect(AIUsageCurrency.usd.currencyCode == "USD")
|
||||
#expect(AIUsageCurrency.cny.currencyCode == "CNY")
|
||||
}
|
||||
|
||||
@Test(arguments: [Double.nan, Double.infinity, -Double.infinity, -1])
|
||||
func rejectsInvalidMoney(_ value: Double) {
|
||||
#expect(AIUsageFormat.money(usd: value, currency: .usd, usdToCNY: nil,
|
||||
locale: Locale(identifier: "en_US")) == nil)
|
||||
}
|
||||
|
||||
@Test(arguments: [Double.nan, Double.infinity, -Double.infinity, -1, 0])
|
||||
func rejectsInvalidRates(_ rate: Double) {
|
||||
#expect(AIUsageFormat.money(usd: 1, currency: .cny, usdToCNY: rate,
|
||||
locale: Locale(identifier: "zh_CN")) == nil)
|
||||
}
|
||||
|
||||
@Test func rejectsConversionOverflow() {
|
||||
#expect(AIUsageFormat.money(usd: Double.greatestFiniteMagnitude, currency: .cny,
|
||||
usdToCNY: 2, locale: Locale(identifier: "zh_CN")) == nil)
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,10 @@ struct LanguageSupportTests {
|
||||
let catalog = try #require(Translations.catalog(for: language))
|
||||
#expect(catalog.sourceKeys == Translations.shared.sourceKeys)
|
||||
for key in ["语言", "应用 UI 语言", "自动检测", "搜索语言", "没有匹配的语言",
|
||||
"额度显示", "数字单位", "货币", "估算费用",
|
||||
"AI 用量与额度共用此间隔;新数据到达后更新菜单栏。",
|
||||
"价格来源:models.dev;人民币按缓存汇率换算。",
|
||||
"按模型基础 API 单价估算,不含阶梯加价,非订阅账单。",
|
||||
"设置", "内存", "下载并应用", "中国大陆节假日与调休", "黄历", "每日", "每周", "累计",
|
||||
"XStats 基于 OpenStats 开发,感谢原项目的开源贡献 · MIT License"] {
|
||||
#expect(catalog.translate(key)?.isEmpty == false)
|
||||
|
||||
@@ -166,6 +166,8 @@ func snapshotPreservesEveryWeekStartAndReloadsCalendars(firstWeekday: Int) throw
|
||||
#expect(snapshot.todayTokens(for: "codex") == nil)
|
||||
#expect(snapshot.currentQuotas().count == 1)
|
||||
#expect(snapshot.localUsageEnabled == nil)
|
||||
#expect(snapshot.quotaShowsRemaining == nil)
|
||||
#expect(snapshot.westernUnits == nil)
|
||||
}
|
||||
|
||||
@Test func onlyWidgetsWhoseDisplayedDataChangedAreReloaded() {
|
||||
@@ -225,3 +227,19 @@ func snapshotPreservesEveryWeekStartAndReloadsCalendars(firstWeekday: Int) throw
|
||||
WidgetKind.calendar, WidgetKind.tomorrowWork,
|
||||
WidgetKind.calendarMonth])
|
||||
}
|
||||
|
||||
@Test func displayPreferencesPreserveLegacyDefaultsAndReloadOnlyAffectedWidgets() throws {
|
||||
let legacy = WidgetSnapshot()
|
||||
var current = legacy
|
||||
current.quotaShowsRemaining = true
|
||||
current.westernUnits = false
|
||||
#expect(current.changedWidgetKinds(from: legacy).isEmpty)
|
||||
current.quotaShowsRemaining = false
|
||||
#expect(current.changedWidgetKinds(from: legacy) == [WidgetKind.aiQuota])
|
||||
current.quotaShowsRemaining = true
|
||||
current.westernUnits = true
|
||||
#expect(current.changedWidgetKinds(from: legacy) == [WidgetKind.todayTokens])
|
||||
let restored = try JSONDecoder().decode(WidgetSnapshot.self, from: JSONEncoder().encode(current))
|
||||
#expect(restored.westernUnits == true)
|
||||
#expect(restored.quotaShowsRemaining == true)
|
||||
}
|
||||
@@ -724,3 +724,72 @@ private actor GatedUsageProvider: AIUsageProvider {
|
||||
#expect(await provider.count() == 0)
|
||||
}
|
||||
}
|
||||
|
||||
private actor CancellableCostFetch {
|
||||
private var started = false
|
||||
private var waiter: CheckedContinuation<Void, Never>?
|
||||
private(set) var cancelled = false
|
||||
|
||||
func fetch() async throws -> Data {
|
||||
started = true
|
||||
waiter?.resume(); waiter = nil
|
||||
do {
|
||||
try await Task.sleep(for: .seconds(60))
|
||||
return Data()
|
||||
} catch {
|
||||
cancelled = Task.isCancelled
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func waitUntilStarted() async {
|
||||
if started { return }
|
||||
await withCheckedContinuation { waiter = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
extension AIUsageControllerTests {
|
||||
@Test(arguments: [true, false])
|
||||
func costRequestCancelsWithSystemPauseOrViewTask(systemPause: Bool) async {
|
||||
let settings = AppSettings(defaults: defaultsForAIUsage())
|
||||
settings.aiUsageEnabled = true
|
||||
let fetch = CancellableCostFetch()
|
||||
let store = AICostReferenceStore(cacheDirectory: nil, fetch: { _, _ in try await fetch.fetch() })
|
||||
let controller = AIUsageController(settings: settings,
|
||||
providers: [StubUsageProvider([.success(usageSnapshot(tokens: 100))])], costReferenceStore: store)
|
||||
await controller.refresh()
|
||||
let task = Task { await controller.refreshCostReferences() }
|
||||
await fetch.waitUntilStarted()
|
||||
#expect(controller.isRefreshingCostReferences)
|
||||
if systemPause { controller.setPaused(true) } else { task.cancel() }
|
||||
await task.value
|
||||
#expect(await fetch.cancelled)
|
||||
#expect(!controller.isRefreshingCostReferences)
|
||||
#expect(controller.costReferences.catalog == nil)
|
||||
controller.stop()
|
||||
}
|
||||
|
||||
@Test func displayPreferencesInvalidateOnlyRelevantMenuBarImages() async {
|
||||
let settings = AppSettings(defaults: defaultsForAIUsage())
|
||||
settings.aiUsageEnabled = true
|
||||
let snapshot = AIQuotaSnapshot(provider: .codex, windows: [
|
||||
AIQuotaWindow(kind: .weekly, usedPercent: 73, resetsAt: nil),
|
||||
], fetchedAt: Date())
|
||||
let model = AppModel(settings: settings, historyURL: nil, aiUsageProviders: [],
|
||||
aiQuotaProviders: [StubQuotaProvider(results: [.success(snapshot)])])
|
||||
await model.aiUsage.refresh()
|
||||
let remaining = MenuBarReading(model: model)
|
||||
settings.aiQuotaShowsRemaining = false
|
||||
let used = MenuBarReading(model: model)
|
||||
#expect(!used.aiQuotaShowsRemaining)
|
||||
#expect(!used.hasSameImage(as: remaining, item: .aiUsage, style: .stacked))
|
||||
#expect(used.hasSameImage(as: remaining, item: .cpu, style: .stacked))
|
||||
#expect(used.aiQuotas.first?.remainingPercent == 27)
|
||||
var tokens = remaining
|
||||
tokens.aiQuotas = []
|
||||
tokens.aiTokens = 123_456
|
||||
var western = tokens
|
||||
western.aiUsageWesternUnits = true
|
||||
#expect(!western.hasSameImage(as: tokens, item: .aiUsage, style: .stacked))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Copyright (C) 2026 ysicing
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import XStatsUI
|
||||
|
||||
@MainActor
|
||||
struct AIUsageSettingsTests {
|
||||
@Test func oldPreferencesKeepDefaultsAndNewChoicesPersist() throws {
|
||||
let name = "AIUsageSettingsTests.\(UUID())"
|
||||
let defaults = try #require(UserDefaults(suiteName: name))
|
||||
defer { defaults.removePersistentDomain(forName: name) }
|
||||
let settings = AppSettings(defaults: defaults)
|
||||
#expect(settings.aiQuotaShowsRemaining)
|
||||
#expect(!settings.aiUsageWesternUnits)
|
||||
#expect(settings.aiUsageCurrency == .usd)
|
||||
#expect(settings.aiUsageRefreshMinutes == 30)
|
||||
settings.aiQuotaShowsRemaining = false
|
||||
settings.aiUsageWesternUnits = true
|
||||
settings.aiUsageCurrency = .cny
|
||||
for interval in [1, 3, 5, 10, 15, 30, 60] {
|
||||
settings.aiUsageRefreshMinutes = interval
|
||||
let restored = AppSettings(defaults: defaults)
|
||||
#expect(restored.aiUsageRefreshMinutes == interval)
|
||||
#expect(!restored.aiQuotaShowsRemaining)
|
||||
#expect(restored.aiUsageWesternUnits)
|
||||
#expect(restored.aiUsageCurrency == .cny)
|
||||
}
|
||||
defaults.set("invalid", forKey: "aiUsageCurrency")
|
||||
defaults.set(0, forKey: "aiUsageRefreshMinutes")
|
||||
let repaired = AppSettings(defaults: defaults)
|
||||
#expect(repaired.aiUsageCurrency == .usd)
|
||||
#expect(repaired.aiUsageRefreshMinutes == 30)
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,14 @@ import Testing
|
||||
@Suite struct UsageNumberTests {
|
||||
@Test func chineseUsesReadableTokenUnits() {
|
||||
let locale = Locale(identifier: "zh_Hans_CN")
|
||||
#expect(UsageNumber.short(1_374_863_940, locale: locale) == "13.7 亿")
|
||||
#expect(UsageNumber.short(1_234_567, locale: locale) == "1.2 百万")
|
||||
#expect(UsageNumber.short(12_345, locale: locale) == "1.2 万")
|
||||
#expect(UsageNumber.short(1_374_863_940, locale: locale) == "13.75亿")
|
||||
#expect(UsageNumber.short(1_234_567, locale: locale) == "123.5万")
|
||||
#expect(UsageNumber.short(12_345, locale: locale) == "1.2万")
|
||||
#expect(UsageNumber.short(0, locale: locale) == "0")
|
||||
#expect(UsageNumber.short(999, locale: locale) == "999")
|
||||
}
|
||||
@Test func englishAndTraditionalChineseKeepTheirUnits() {
|
||||
#expect(UsageNumber.short(1_374_863_940, locale: Locale(identifier: "en_US")) == "1.4B")
|
||||
#expect(UsageNumber.short(1_374_863_940, locale: Locale(identifier: "zh_Hant_TW")) == "13.7 億")
|
||||
#expect(UsageNumber.short(1_374_863_940, locale: Locale(identifier: "en_US")) == "1.37B")
|
||||
#expect(UsageNumber.short(1_374_863_940, locale: Locale(identifier: "zh_Hant_TW")) == "13.75億")
|
||||
}
|
||||
}
|
||||
@@ -114,14 +114,14 @@ private struct AIQuotaWidgetView: View {
|
||||
Text(quota.provider == "codex" ? "Codex" : "Claude")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text("\(Int(quota.remainingPercent.rounded()))%")
|
||||
Text(AIUsageFormat.quotaPercent(remainingPercent: quota.remainingPercent, showsRemaining: entry.snapshot.quotaShowsRemaining != false, locale: L10n.locale))
|
||||
.font(.system(size: 27, weight: .semibold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
Text(tr("剩余"))
|
||||
Text(tr(entry.snapshot.quotaShowsRemaining != false ? "剩余" : "已用"))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
ProgressView(value: quota.remainingPercent, total: 100)
|
||||
ProgressView(value: AIUsageFormat.quotaValue(remainingPercent: quota.remainingPercent, showsRemaining: entry.snapshot.quotaShowsRemaining != false) ?? 0, total: 100)
|
||||
.tint(quota.remainingPercent < 20 ? .red : .blue)
|
||||
if let reset = quota.resetsAt {
|
||||
Text(tr("重置:") + " " + reset.formatted(date: .abbreviated, time: .shortened))
|
||||
@@ -168,7 +168,7 @@ private struct TodayTokensWidgetView: View {
|
||||
WidgetEmptyState(enabled: entry.snapshot.showsLocalUsage)
|
||||
} else {
|
||||
let total = sources.reduce(0) { $0 + $1.tokens }
|
||||
Text(total.formatted(.number.notation(.compactName).locale(L10n.locale)))
|
||||
Text(AIUsageFormat.tokens(total, westernUnits: entry.snapshot.westernUnits == true, locale: L10n.locale))
|
||||
.font(.system(size: 36, weight: .semibold, design: .rounded))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
@@ -178,7 +178,7 @@ private struct TodayTokensWidgetView: View {
|
||||
HStack {
|
||||
Text(source.name)
|
||||
Spacer(minLength: 4)
|
||||
Text(source.tokens.formatted(.number.notation(.compactName).locale(L10n.locale)))
|
||||
Text(AIUsageFormat.tokens(source.tokens, westernUnits: entry.snapshot.westernUnits == true, locale: L10n.locale))
|
||||
.monospacedDigit()
|
||||
}
|
||||
.font(.caption)
|
||||
|
||||
+2
-1
@@ -24,7 +24,7 @@
|
||||
<div class="section-head">
|
||||
<p><a href="./">← 返回 XStats</a></p>
|
||||
<h2>隐私政策</h2>
|
||||
<p>最后更新:2026-10-02。本页说明 XStats 与区域更新服务会处理哪些数据。</p>
|
||||
<p>最后更新:2026-10-03。本页说明 XStats 与区域更新服务会处理哪些数据。</p>
|
||||
</div>
|
||||
|
||||
<div class="section-head">
|
||||
@@ -33,6 +33,7 @@
|
||||
</div>
|
||||
<ul class="fact-list">
|
||||
<li><b>日程与提醒事项</b><span>默认关闭;主动授权后仅在日历面板打开时读取所选列表的月历范围,在本机只读展示,不上传、不修改记录,也不写入设置备份或小组件摘要。可在日历设置中关闭或在系统设置中撤销权限。</span></li>
|
||||
<li><b>AI 费用估算</b><span>启用本地 AI 用量且界面可见时,按需获取 models.dev 公开基础单价;选择人民币时另获取 open.er-api.com 参考汇率,分别缓存 24 小时和 12 小时。不发送日志、Token 数量、模型使用记录或登录凭据;第三方可看到连接元数据。失败时可显示带更新时间的旧缓存,关闭界面或功能后取消不再需要的请求。估算不含阶梯加价,非订阅账单。</span></li>
|
||||
<li><b>公网 IP</b><span>打开网络详情时向 Cloudflare 查询一次公网地址,10 分钟内不重复,可在设置中关闭</span></li>
|
||||
<li><b>归属地</b><span>归属地、ASN、网络类型与纯净度评分由 cleanip.io 提供:这台 Mac 直接向它查询自己的公网 IP,只发送这个地址、不带其他信息,不经过 XStats 的服务器</span></li>
|
||||
<li><b>连接探测</b><span>打开网络详情时每秒 ping 一次你选择的目标(后台每 10 秒),只在显示网络项时运行,可关闭</span></li>
|
||||
|
||||
Reference in new issue
Block a user