feat(ai-usage): 增加本地用量开关并自动查询额度
This commit is contained in:
1 parent
9127f04ce7
commit
59603dcbd2
25 files changed
+213
-45
No files matched your search
+2
-2
@@ -168,12 +168,12 @@ git push
|
||||
- `Helper/`:特权辅助工具及 launchd 配置。
|
||||
- `Packages/XStatsKit/Sources/SMC`:SMC 通信、温度传感器和风扇控制。
|
||||
- `Packages/XStatsKit/Sources/Metrics`:CPU、内存、网络、GPU、磁盘、电池、进程和传感器采集。
|
||||
- `Packages/XStatsKit/Sources/AIUsage`:本机 Codex / Claude Code 会话日志的只读扫描、Token 统计与增量检查点。
|
||||
- `Packages/XStatsKit/Sources/AIUsage`:本机 Codex / Claude Code 会话日志的只读扫描、Token 统计与增量检查点;订阅额度在 AI 模块启用后自动查询,“显示本地用量”只控制日志扫描与本地 Token 展示。
|
||||
- `Packages/XStatsKit/Sources/Cleaner`:清理规则、安全守卫、扫描和执行。
|
||||
- `Packages/XStatsKit/Sources/Updates`:版本清单、下载校验和应用替换。
|
||||
- `Packages/XStatsKit/Sources/HelperShared`:应用与辅助工具共用的 XPC 协议和维护命令。
|
||||
- `Packages/XStatsKit/Sources/WebDAVSync`:WebDAV 同步和钥匙串密码存储。
|
||||
- `Packages/XStatsKit/Sources/WidgetData`:主应用与桌面小组件共享的无凭据展示摘要。
|
||||
- `Packages/XStatsKit/Sources/WidgetData`:主应用与桌面小组件共享的无凭据展示摘要;AI 额度沿用模块总开关,本地用量开关单独传给 Widget,旧摘要缺少新字段时沿用原有总开关。
|
||||
- `Packages/XStatsKit/Sources/XStatsUI`:界面、设置、弹窗和菜单栏渲染。
|
||||
- `Packages/XStatsKit/Tests`:Swift 单元测试。
|
||||
|
||||
|
||||
@@ -1267,6 +1267,9 @@ Codex / Claude Code 本机 Token 用量与订阅额度 استخدام رموز C
|
||||
重置: Resets:
|
||||
查询失败,显示上次额度 Query failed; showing previous quota
|
||||
上次额度 الحصة السابقة
|
||||
显示本地用量 عرض الاستخدام المحلي
|
||||
读取本机会话日志;关闭后停止扫描并隐藏 Token 统计。 يقرأ سجلات الجلسات المحلية؛ يؤدي إيقافه إلى وقف الفحص وإخفاء إحصاءات الرموز.
|
||||
自动检测 Codex / Claude 订阅额度;本地 Token 统计可单独关闭。 يكتشف حصص اشتراك Codex وClaude تلقائيًا؛ ويمكن إيقاف إحصاءات الرموز المحلية بشكل منفصل.
|
||||
暂无额度数据 No quota data yet
|
||||
未检测到登录凭据,请先登录对应 CLI No login credentials found; sign in with the corresponding CLI
|
||||
登录已失效,请在对应 CLI 重新登录 Login expired; sign in again with the corresponding CLI
|
||||
|
||||
|
@@ -1267,6 +1267,9 @@ Codex / Claude Code 本机 Token 用量与订阅额度 Lokale Codex / Claude Cod
|
||||
重置: Resets:
|
||||
查询失败,显示上次额度 Query failed; showing previous quota
|
||||
上次额度 Vorheriges Kontingent
|
||||
显示本地用量 Lokale Nutzung anzeigen
|
||||
读取本机会话日志;关闭后停止扫描并隐藏 Token 统计。 Liest lokale Sitzungsprotokolle; beim Ausschalten werden Scans beendet und Tokenstatistiken ausgeblendet.
|
||||
自动检测 Codex / Claude 订阅额度;本地 Token 统计可单独关闭。 Erkennt Codex-/Claude-Abonnementkontingente automatisch; lokale Tokenstatistiken lassen sich separat deaktivieren.
|
||||
暂无额度数据 No quota data yet
|
||||
未检测到登录凭据,请先登录对应 CLI No login credentials found; sign in with the corresponding CLI
|
||||
登录已失效,请在对应 CLI 重新登录 Login expired; sign in again with the corresponding CLI
|
||||
|
||||
|
@@ -1267,6 +1267,9 @@ Codex / Claude Code 本机 Token 用量与订阅额度 Tokens locales de Codex /
|
||||
重置: Resets:
|
||||
查询失败,显示上次额度 Query failed; showing previous quota
|
||||
上次额度 Cuota anterior
|
||||
显示本地用量 Mostrar uso local
|
||||
读取本机会话日志;关闭后停止扫描并隐藏 Token 统计。 Lee los registros de sesión locales; al desactivarlo, detiene el análisis y oculta las estadísticas de tokens.
|
||||
自动检测 Codex / Claude 订阅额度;本地 Token 统计可单独关闭。 Detecta automáticamente las cuotas de suscripción de Codex / Claude; las estadísticas locales de tokens pueden desactivarse por separado.
|
||||
暂无额度数据 No quota data yet
|
||||
未检测到登录凭据,请先登录对应 CLI No login credentials found; sign in with the corresponding CLI
|
||||
登录已失效,请在对应 CLI 重新登录 Login expired; sign in again with the corresponding CLI
|
||||
|
||||
|
@@ -1267,6 +1267,9 @@ Codex / Claude Code 本机 Token 用量与订阅额度 Tokens locaux Codex / Cla
|
||||
重置: Resets:
|
||||
查询失败,显示上次额度 Query failed; showing previous quota
|
||||
上次额度 Quota précédent
|
||||
显示本地用量 Afficher l’utilisation locale
|
||||
读取本机会话日志;关闭后停止扫描并隐藏 Token 统计。 Lit les journaux de session locaux ; la désactivation arrête l’analyse et masque les statistiques de tokens.
|
||||
自动检测 Codex / Claude 订阅额度;本地 Token 统计可单独关闭。 Détecte automatiquement les quotas d’abonnement Codex / Claude ; les statistiques locales de tokens peuvent être désactivées séparément.
|
||||
暂无额度数据 No quota data yet
|
||||
未检测到登录凭据,请先登录对应 CLI No login credentials found; sign in with the corresponding CLI
|
||||
登录已失效,请在对应 CLI 重新登录 Login expired; sign in again with the corresponding CLI
|
||||
|
||||
|
@@ -1267,6 +1267,9 @@ Codex / Claude Code 本机 Token 用量与订阅额度 Codex / Claude Code の
|
||||
重置: Resets:
|
||||
查询失败,显示上次额度 Query failed; showing previous quota
|
||||
上次额度 前回の利用枠
|
||||
显示本地用量 ローカル使用量を表示
|
||||
读取本机会话日志;关闭后停止扫描并隐藏 Token 统计。 ローカルのセッションログを読み取ります。オフにするとスキャンを停止し、トークン統計を非表示にします。
|
||||
自动检测 Codex / Claude 订阅额度;本地 Token 统计可单独关闭。 Codex / Claude のサブスクリプション利用枠を自動検出します。ローカルのトークン統計は個別にオフにできます。
|
||||
暂无额度数据 No quota data yet
|
||||
未检测到登录凭据,请先登录对应 CLI No login credentials found; sign in with the corresponding CLI
|
||||
登录已失效,请在对应 CLI 重新登录 Login expired; sign in again with the corresponding CLI
|
||||
|
||||
|
@@ -1267,6 +1267,9 @@ Codex / Claude Code 本机 Token 用量与订阅额度 Codex / Claude Code 로
|
||||
重置: Resets:
|
||||
查询失败,显示上次额度 Query failed; showing previous quota
|
||||
上次额度 이전 한도
|
||||
显示本地用量 로컬 사용량 표시
|
||||
读取本机会话日志;关闭后停止扫描并隐藏 Token 统计。 로컬 세션 로그를 읽습니다. 끄면 검사를 중지하고 토큰 통계를 숨깁니다.
|
||||
自动检测 Codex / Claude 订阅额度;本地 Token 统计可单独关闭。 Codex / Claude 구독 한도를 자동으로 감지합니다. 로컬 토큰 통계는 별도로 끌 수 있습니다.
|
||||
暂无额度数据 No quota data yet
|
||||
未检测到登录凭据,请先登录对应 CLI No login credentials found; sign in with the corresponding CLI
|
||||
登录已失效,请在对应 CLI 重新登录 Login expired; sign in again with the corresponding CLI
|
||||
|
||||
|
@@ -1268,6 +1268,9 @@ Codex / Claude Code 本机 Token 用量与订阅额度 Codex / Claude Code 本
|
||||
重置: 重置:
|
||||
查询失败,显示上次额度 查詢失敗,顯示上次額度
|
||||
上次额度 上次額度
|
||||
显示本地用量 顯示本機用量
|
||||
读取本机会话日志;关闭后停止扫描并隐藏 Token 统计。 讀取本機工作階段記錄;關閉後停止掃描並隱藏 Token 統計。
|
||||
自动检测 Codex / Claude 订阅额度;本地 Token 统计可单独关闭。 自動偵測 Codex / Claude 訂閱額度;本機 Token 統計可單獨關閉。
|
||||
暂无额度数据 暫無額度資料
|
||||
未检测到登录凭据,请先登录对应 CLI 未偵測到登入憑證,請先登入對應的 CLI
|
||||
登录已失效,请在对应 CLI 重新登录 登入已失效,請在對應的 CLI 重新登入
|
||||
|
||||
|
@@ -25,6 +25,9 @@ Token 活动 Token activity
|
||||
截至 {} 当周累计:{} Tokens Through the week of {}: {} Tokens cumulative
|
||||
缓存创建 Token Cache creation tokens
|
||||
AI 用量与额度 AI Usage & Quotas
|
||||
显示本地用量 Show local usage
|
||||
读取本机会话日志;关闭后停止扫描并隐藏 Token 统计。 Reads local session logs; turning this off stops scanning and hides token statistics.
|
||||
自动检测 Codex / Claude 订阅额度;本地 Token 统计可单独关闭。 Automatically detects Codex / Claude subscription quotas; local token statistics can be turned off separately.
|
||||
Codex / Claude Code 本机 Token 用量与订阅额度 Local Codex / Claude Code tokens and subscription quotas
|
||||
今天 Today
|
||||
模型 Model
|
||||
|
||||
@@ -119,6 +119,8 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable {
|
||||
public var quotas: [Quota]
|
||||
/// 仅共享每个来源的当日合计;可选以兼容旧版 App Group 摘要。
|
||||
public var dailyTokens: [DailyTokenUsage]?
|
||||
/// 可选以兼容旧版摘要;缺失时沿用 AI 总开关的旧行为。
|
||||
public var localUsageEnabled: Bool?
|
||||
public var publicIPEnabled: Bool
|
||||
public var addresses: [Address]
|
||||
public var language: String
|
||||
@@ -128,6 +130,7 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable {
|
||||
public var calendarDays: [CalendarSummary]?
|
||||
|
||||
public init(aiEnabled: Bool = false, quotas: [Quota] = [], dailyTokens: [DailyTokenUsage]? = nil,
|
||||
localUsageEnabled: Bool? = nil,
|
||||
publicIPEnabled: Bool = false,
|
||||
addresses: [Address] = [], language: String = "system",
|
||||
calendarFirstWeekday: Int = 2, showsLunar: Bool = true,
|
||||
@@ -135,6 +138,7 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable {
|
||||
self.aiEnabled = aiEnabled
|
||||
self.quotas = quotas
|
||||
self.dailyTokens = dailyTokens
|
||||
self.localUsageEnabled = localUsageEnabled
|
||||
self.publicIPEnabled = publicIPEnabled
|
||||
self.addresses = addresses
|
||||
self.language = language
|
||||
@@ -143,6 +147,8 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable {
|
||||
self.calendarDays = calendarDays
|
||||
}
|
||||
|
||||
public var showsLocalUsage: Bool { aiEnabled && localUsageEnabled != false }
|
||||
|
||||
/// 已过重置时间的缓存不再代表当前额度,Widget 不应继续显示旧百分比。
|
||||
public func currentQuotas(at date: Date = .now) -> [Quota] {
|
||||
guard aiEnabled else { return [] }
|
||||
@@ -152,7 +158,7 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable {
|
||||
/// 旧日快照保留来源,但数值归零;Widget 不得把昨天的使用量当作今天的结果。
|
||||
public func todayTokens(for provider: String, at date: Date = .now,
|
||||
calendar: Calendar = .autoupdatingCurrent) -> Int? {
|
||||
guard aiEnabled, let usage = dailyTokens?.first(where: { $0.provider == provider }) else { return nil }
|
||||
guard showsLocalUsage, let usage = dailyTokens?.first(where: { $0.provider == provider }) else { return nil }
|
||||
return calendar.isDate(usage.day, inSameDayAs: date) ? usage.tokens : 0
|
||||
}
|
||||
|
||||
@@ -166,10 +172,11 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable {
|
||||
resetsAt: $0.resetsAt, fetchedAt: .distantPast, isStale: $0.isStale) }
|
||||
}
|
||||
let relocalized = language != previous.language
|
||||
let ai = relocalized || aiEnabled != previous.aiEnabled
|
||||
let quotaChanged = relocalized || aiEnabled != previous.aiEnabled
|
||||
let localUsageChanged = relocalized || showsLocalUsage != previous.showsLocalUsage
|
||||
var kinds: [String] = []
|
||||
if ai || displayed(quotas) != displayed(previous.quotas) { kinds.append(WidgetKind.aiQuota) }
|
||||
if ai || dailyTokens != previous.dailyTokens { kinds.append(WidgetKind.todayTokens) }
|
||||
if quotaChanged || displayed(quotas) != displayed(previous.quotas) { kinds.append(WidgetKind.aiQuota) }
|
||||
if localUsageChanged || dailyTokens != previous.dailyTokens { kinds.append(WidgetKind.todayTokens) }
|
||||
if relocalized || publicIPEnabled != previous.publicIPEnabled
|
||||
|| visibleAddresses != previous.visibleAddresses {
|
||||
kinds += [WidgetKind.ipPurity, WidgetKind.publicIP]
|
||||
|
||||
@@ -310,6 +310,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
withObservationTracking {
|
||||
_ = model.settings.aiUsageEnabled
|
||||
_ = model.settings.aiUsageSources
|
||||
_ = model.settings.aiUsageShowsLocalUsage
|
||||
_ = model.settings.aiUsageRefreshMinutes
|
||||
} onChange: { [weak self] in
|
||||
Task { @MainActor in
|
||||
@@ -343,6 +344,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
withObservationTracking {
|
||||
_ = model.settings.aiUsageEnabled
|
||||
_ = model.settings.aiUsageSources
|
||||
_ = model.settings.aiUsageShowsLocalUsage
|
||||
_ = model.settings.publicIPLookup
|
||||
_ = model.settings.language
|
||||
_ = model.settings.calendarFirstWeekday
|
||||
@@ -387,9 +389,9 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
fetchedAt: quota.fetchedAt, isStale: state.isStale)
|
||||
}
|
||||
} : []
|
||||
let dailyTokens: [WidgetSnapshot.DailyTokenUsage]? = settings.aiUsageEnabled
|
||||
let dailyTokens: [WidgetSnapshot.DailyTokenUsage]? = settings.aiUsageEnabled && settings.aiUsageShowsLocalUsage
|
||||
? AIProviderID.allCases.filter { settings.aiUsageSources.contains($0) }.compactMap { provider in
|
||||
guard let report = model.aiUsage.state(for: provider).snapshot?.localUsage else { return nil }
|
||||
guard let report = model.aiUsage.localReport(for: provider) else { return nil }
|
||||
let tokens = LocalUsageReport.total(report.selected(days: 1, now: today, calendar: calendar)).total
|
||||
return WidgetSnapshot.DailyTokenUsage(provider: provider.rawValue, day: today, tokens: tokens)
|
||||
} : nil
|
||||
@@ -408,6 +410,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
|
||||
} : []
|
||||
let snapshot = WidgetSnapshot(aiEnabled: settings.aiUsageEnabled, quotas: quotas,
|
||||
dailyTokens: dailyTokens,
|
||||
localUsageEnabled: settings.aiUsageShowsLocalUsage,
|
||||
publicIPEnabled: settings.publicIPLookup, addresses: addresses,
|
||||
language: settings.language.rawValue,
|
||||
calendarFirstWeekday: settings.calendarFirstWeekday,
|
||||
|
||||
@@ -45,6 +45,7 @@ struct MenuBarReading {
|
||||
var batteryHistory: [Double] = []
|
||||
/// 本机日志今日 Token 总量;扫描未开启或还没有数据时为 nil
|
||||
var aiTokens: Int?
|
||||
var aiUsageShowsLocalUsage = true
|
||||
/// 只有可显示额度的来源才占用菜单栏读数;本机 Token 统计独立于订阅额度。
|
||||
var aiQuotas: [MenuBarQuota] = []
|
||||
var batteryCharging = false
|
||||
@@ -73,6 +74,7 @@ struct MenuBarReading {
|
||||
battery = store.battery?.level
|
||||
batteryHistory = battery.map { Array(repeating: $0, count: 30) } ?? []
|
||||
aiTokens = model.aiUsage.todayTokens
|
||||
aiUsageShowsLocalUsage = model.settings.aiUsageShowsLocalUsage
|
||||
aiQuotas = model.aiUsage.visibleQuotaProviders(for: nil).compactMap { provider in
|
||||
let state = model.aiUsage.visibleQuotaState(for: provider)
|
||||
guard let snapshot = state.snapshot else { return nil }
|
||||
@@ -159,7 +161,8 @@ struct MenuBarReading {
|
||||
return "\(previous)\(quota.sourceName)\(quota.source == .sub2api ? " (Sub2API)" : "") · \(quota.shortWindowName) · \(tr("剩余")) \(quota.remainingPercent)% · \(tr("重置:")) \(quota.resetText)\(checked)"
|
||||
}.joined(separator: "\n")
|
||||
} else {
|
||||
aiTokens.map { "AI · \(UsageNumber.exact($0)) Tokens" } ?? ("AI · " + tr("暂无本机用量数据"))
|
||||
aiTokens.map { "AI · \(UsageNumber.exact($0)) Tokens" }
|
||||
?? ("AI · " + tr(aiUsageShowsLocalUsage ? "暂无本机用量数据" : "暂无额度数据"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ private struct LocalUsageContent: View {
|
||||
if !model.settings.aiUsageEnabled {
|
||||
Card {
|
||||
Label(tr("AI 用量与额度"), systemImage: "chart.bar").dsFont(.base, weight: .semibold)
|
||||
Text(tr("启用后读取本机会话日志,并使用本机登录凭据向 Codex / Claude 查询订阅额度。"))
|
||||
Text(tr("自动检测 Codex / Claude 订阅额度;本地 Token 统计可单独关闭。"))
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Button(tr("启用使用统计")) { model.settings.aiUsageEnabled = true }
|
||||
@@ -63,24 +63,28 @@ private struct LocalUsageContent: View {
|
||||
Button(tr("设置")) { model.openAIUsageSettings() }
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
}
|
||||
} else if let report = model.aiUsage.localReport(for: provider) {
|
||||
let rows = report.summary(mode: activityMode, model: selectedModel.isEmpty ? nil : selectedModel)
|
||||
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)
|
||||
UsageHeatmap(rows: activityRows, compact: compact, mode: $activityMode)
|
||||
modelRanking(report: report, rows: rows, total: total)
|
||||
status(report)
|
||||
} else {
|
||||
VStack(spacing: DS.Space.s3) {
|
||||
if model.aiUsage.isRefreshing { ProgressView().controlSize(.small) }
|
||||
Image(systemName: "chart.bar.xaxis").font(.system(size: DS.TextSize.xl.rawValue))
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
Text(tr(model.aiUsage.isRefreshing ? "正在读取会话日志…" : "暂无本机用量数据"))
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
|
||||
} else if model.settings.aiUsageShowsLocalUsage {
|
||||
if let report = model.aiUsage.localReport(for: provider) {
|
||||
let rows = report.summary(mode: activityMode, model: selectedModel.isEmpty ? nil : selectedModel)
|
||||
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)
|
||||
UsageHeatmap(rows: activityRows, compact: compact, mode: $activityMode)
|
||||
modelRanking(report: report, rows: rows, total: total)
|
||||
status(report)
|
||||
} else {
|
||||
VStack(spacing: DS.Space.s3) {
|
||||
if model.aiUsage.isRefreshing { ProgressView().controlSize(.small) }
|
||||
Image(systemName: "chart.bar.xaxis").font(.system(size: DS.TextSize.xl.rawValue))
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
Text(tr(model.aiUsage.isRefreshing ? "正在读取会话日志…" : "暂无本机用量数据"))
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity).padding(.vertical, DS.Space.s12)
|
||||
}
|
||||
.frame(maxWidth: .infinity).padding(.vertical, DS.Space.s12)
|
||||
} else if model.aiUsage.visibleQuotaProviders(for: provider).isEmpty {
|
||||
Card { Text(tr("暂无额度数据")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary) }
|
||||
}
|
||||
}
|
||||
.onChange(of: model.settings.aiUsageSources) { _, sources in
|
||||
@@ -667,7 +671,7 @@ struct AIUsageSettingsButton: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
MiniIconButton(systemName: "slider.horizontal.3", help: tr("AI 用量与额度") + " · " + tr("数据来源")) {
|
||||
MiniIconButton(systemName: "slider.horizontal.3", help: tr("AI 用量与额度") + " · " + tr("设置")) {
|
||||
model.openAIUsageSettings()
|
||||
}
|
||||
}
|
||||
@@ -691,6 +695,10 @@ private struct UsageSettings: View {
|
||||
SettingRow(title: tr("AI 用量与额度"), subtitle: nil) {
|
||||
DSToggle(isOn: $settings.aiUsageEnabled, label: tr("AI 用量与额度"))
|
||||
}
|
||||
SettingRow(title: tr("显示本地用量"),
|
||||
subtitle: tr("读取本机会话日志;关闭后停止扫描并隐藏 Token 统计。")) {
|
||||
DSToggle(isOn: $settings.aiUsageShowsLocalUsage, label: tr("显示本地用量"))
|
||||
}
|
||||
HairlineDivider()
|
||||
Text(tr("数据来源")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
|
||||
ForEach(AIProviderID.allCases) { provider in
|
||||
|
||||
@@ -309,7 +309,15 @@ private struct OverviewReading {
|
||||
detail = tr("没有电池")
|
||||
}
|
||||
case .aiUsage:
|
||||
if let tokens = model.aiUsage.todayTokens {
|
||||
if !model.settings.aiUsageShowsLocalUsage {
|
||||
if let quota = MenuBarReading(model: model).aiQuotas.first {
|
||||
value = "\(quota.remainingPercent)"
|
||||
unit = "%"
|
||||
detail = quota.sourceName + " · " + tr("订阅额度")
|
||||
} else {
|
||||
detail = tr("暂无额度数据")
|
||||
}
|
||||
} else if let tokens = model.aiUsage.todayTokens {
|
||||
// 与菜单栏、面板共用同一套缩写,同一个数字不能在三处显示成三种样子。
|
||||
value = UsageNumber.short(tokens)
|
||||
unit = "Tokens"
|
||||
|
||||
@@ -107,7 +107,7 @@ struct GeneralSettings: View {
|
||||
SettingsGroup(caption: tr("可选功能")) {
|
||||
GroupRow(showsDivider: false) {
|
||||
SettingRow(title: tr("AI 用量与额度"),
|
||||
subtitle: tr("统计本机会话 Token,并使用本机登录凭据自动查询 Codex / Claude 订阅额度。"),
|
||||
subtitle: tr("自动检测 Codex / Claude 订阅额度;本地 Token 统计可单独关闭。"),
|
||||
icon: "sparkles") {
|
||||
DSToggle(isOn: $settings.aiUsageEnabled, label: tr("AI 用量与额度"))
|
||||
}
|
||||
|
||||
@@ -113,19 +113,23 @@ public final class AIUsageController {
|
||||
}
|
||||
|
||||
public var todayTokens: Int? {
|
||||
guard settings.aiUsageEnabled, let report = localReport(for: nil) else { return nil }
|
||||
guard settings.aiUsageEnabled, settings.aiUsageShowsLocalUsage,
|
||||
let report = localReport(for: nil) else { return nil }
|
||||
return LocalUsageReport.total(report.selected(days: 1, now: now())).total
|
||||
}
|
||||
|
||||
public func localReport(for provider: AIProviderID?) -> LocalUsageReport? {
|
||||
guard settings.aiUsageShowsLocalUsage else { return nil }
|
||||
let reports = states.values.filter { settings.aiUsageSources.contains($0.provider) && (provider == nil || $0.provider == provider) }
|
||||
.compactMap { $0.snapshot?.localUsage }
|
||||
.filter { !$0.rows.isEmpty || $0.fileCount > 0 }
|
||||
guard !reports.isEmpty else { return nil }
|
||||
return LocalUsageReport(rows: reports.flatMap(\.rows), fileCount: reports.reduce(0) { $0 + $1.fileCount },
|
||||
unreadableFiles: reports.reduce(0) { $0 + $1.unreadableFiles })
|
||||
}
|
||||
|
||||
public func localState(for provider: AIProviderID?) -> (stale: Bool, updated: Date?) {
|
||||
guard settings.aiUsageShowsLocalUsage else { return (false, nil) }
|
||||
let selected = states.values.filter { settings.aiUsageSources.contains($0.provider) && (provider == nil || $0.provider == provider) }
|
||||
return (selected.contains { $0.failure != nil }, selected.compactMap { $0.snapshot?.fetchedAt }.min())
|
||||
}
|
||||
@@ -133,7 +137,13 @@ public final class AIUsageController {
|
||||
public func start() {
|
||||
cadenceTask?.cancel()
|
||||
cadenceTask = nil
|
||||
for provider in providers where !settings.aiUsageSources.contains(provider.id) {
|
||||
if !settings.aiUsageShowsLocalUsage {
|
||||
// 关闭日志读取时丢弃在途扫描;额度轮询会在下方重新启动。
|
||||
generation += 1
|
||||
refreshTask?.cancel()
|
||||
refreshTask = nil
|
||||
}
|
||||
for provider in providers where !settings.aiUsageShowsLocalUsage || !settings.aiUsageSources.contains(provider.id) {
|
||||
states[provider.id] = AIUsageProviderState(provider: provider.id)
|
||||
}
|
||||
for provider in quotaProviders where !settings.aiUsageSources.contains(provider.id) {
|
||||
@@ -206,7 +216,7 @@ public final class AIUsageController {
|
||||
async let quotaResults = Self.fetchQuotas(selectedQuotaProviders)
|
||||
|
||||
for provider in providers {
|
||||
guard settings.aiUsageSources.contains(provider.id) else {
|
||||
guard settings.aiUsageShowsLocalUsage, settings.aiUsageSources.contains(provider.id) else {
|
||||
states[provider.id] = AIUsageProviderState(provider: provider.id)
|
||||
continue
|
||||
}
|
||||
@@ -216,7 +226,7 @@ public final class AIUsageController {
|
||||
do {
|
||||
let snapshot = try await provider.fetch()
|
||||
guard currentGeneration == generation, settings.aiUsageEnabled, !Task.isCancelled else { return }
|
||||
guard settings.aiUsageSources.contains(provider.id) else {
|
||||
guard settings.aiUsageShowsLocalUsage, settings.aiUsageSources.contains(provider.id) else {
|
||||
states[provider.id] = AIUsageProviderState(provider: provider.id)
|
||||
continue
|
||||
}
|
||||
@@ -224,12 +234,18 @@ public final class AIUsageController {
|
||||
state.failure = nil
|
||||
} catch let failure as AIUsageFailure {
|
||||
guard currentGeneration == generation, settings.aiUsageEnabled, !Task.isCancelled else { return }
|
||||
guard settings.aiUsageSources.contains(provider.id) else { continue }
|
||||
guard settings.aiUsageShowsLocalUsage, settings.aiUsageSources.contains(provider.id) else {
|
||||
states[provider.id] = AIUsageProviderState(provider: provider.id)
|
||||
continue
|
||||
}
|
||||
// 扫描失败不代表日志消失了,保留上一次的统计结果并在页面上标注为旧数据。
|
||||
state.failure = failure
|
||||
} catch {
|
||||
guard currentGeneration == generation, settings.aiUsageEnabled, !Task.isCancelled else { return }
|
||||
guard settings.aiUsageSources.contains(provider.id) else { continue }
|
||||
guard settings.aiUsageShowsLocalUsage, settings.aiUsageSources.contains(provider.id) else {
|
||||
states[provider.id] = AIUsageProviderState(provider: provider.id)
|
||||
continue
|
||||
}
|
||||
state.failure = .network
|
||||
}
|
||||
state.isRefreshing = false
|
||||
|
||||
@@ -428,7 +428,7 @@ public final class AppSettings {
|
||||
public var refreshSeconds: Int {
|
||||
didSet { defaults.set(refreshSeconds, forKey: Keys.refreshSeconds) }
|
||||
}
|
||||
/// 本机 AI 日志统计默认不启用,独立于系统指标的秒级采样。
|
||||
/// AI 用量模块默认不启用,独立于系统指标的秒级采样。
|
||||
public var aiUsageEnabled: Bool {
|
||||
didSet {
|
||||
defaults.set(aiUsageEnabled, forKey: Keys.aiUsageEnabled)
|
||||
@@ -439,6 +439,10 @@ public final class AppSettings {
|
||||
public var aiUsageSources: Set<AIProviderID> {
|
||||
didSet { defaults.set(aiUsageSources.map(\.rawValue).sorted(), forKey: Keys.aiUsageSources) }
|
||||
}
|
||||
/// 订阅额度继续自动查询;关闭本地用量后停止扫描本机会话日志。
|
||||
public var aiUsageShowsLocalUsage: Bool {
|
||||
didSet { defaults.set(aiUsageShowsLocalUsage, forKey: Keys.aiUsageShowsLocalUsage) }
|
||||
}
|
||||
public var aiUsageRefreshMinutes: Int {
|
||||
didSet { defaults.set(aiUsageRefreshMinutes, forKey: Keys.aiUsageRefreshMinutes) }
|
||||
}
|
||||
@@ -583,6 +587,7 @@ public final class AppSettings {
|
||||
aiUsageEnabled = isAIUsageEnabled
|
||||
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
|
||||
aiUsageRefreshMinutes = Self.aiUsageRefreshOptions.contains(defaults.integer(forKey: Keys.aiUsageRefreshMinutes))
|
||||
? defaults.integer(forKey: Keys.aiUsageRefreshMinutes) : 30
|
||||
colorizeHighLoad = defaults.bool(forKey: Keys.colorizeHighLoad)
|
||||
@@ -676,6 +681,7 @@ public final class AppSettings {
|
||||
static let refreshSeconds = "refreshSeconds"
|
||||
static let aiUsageEnabled = "aiUsageEnabled"
|
||||
static let aiUsageSources = "aiUsageSources"
|
||||
static let aiUsageShowsLocalUsage = "aiUsageShowsLocalUsage"
|
||||
static let aiUsageRefreshMinutes = "aiUsageRefreshMinutes"
|
||||
static let colorizeHighLoad = "colorizeHighLoad"
|
||||
static let bluetoothLowBatteryInMenuBar = "bluetoothLowBatteryInMenuBar"
|
||||
|
||||
@@ -70,6 +70,13 @@ private actor StubQuotaHTTPClient: QuotaHTTPClient {
|
||||
#expect(window.remainingTimeFraction(at: Date()) == nil)
|
||||
}
|
||||
|
||||
@Test func quotaCacheIgnoresRemovedSub2APIStats() throws {
|
||||
// 已安装版本可能把该字段写入 SQLite 快照;移除展示后仍应读出额度。
|
||||
let data = Data(#"{"kind":"weekly","usedPercent":74,"resetsAt":null,"sub2apiStats":{"requests":8,"tokens":12000}}"#.utf8)
|
||||
let window = try JSONDecoder().decode(AIQuotaWindow.self, from: data)
|
||||
#expect(window.remainingPercent == 26)
|
||||
}
|
||||
|
||||
@Test func codexMapsWeeklyWindowEvenWhenServerReversesSlots() async throws {
|
||||
let http = StubQuotaHTTPClient(body: #"{"rate_limit":{"primary_window":{"used_percent":61,"limit_window_seconds":604800,"reset_at":1800000000},"secondary_window":{"used_percent":18,"limit_window_seconds":18000,"reset_at":1799500000}}}"#)
|
||||
let provider = CodexQuotaProvider(credentials: { CodexQuotaCredentials(token: "test-token", accountID: "account-1") }, http: http)
|
||||
|
||||
@@ -92,14 +92,21 @@ import WidgetData
|
||||
#expect(snapshot.todayTokens(for: "claude", at: scannedDay, calendar: shanghai) == nil)
|
||||
let restored = try JSONDecoder().decode(WidgetSnapshot.self, from: JSONEncoder().encode(snapshot))
|
||||
#expect(restored.todayTokens(for: "codex", at: scannedDay, calendar: shanghai) == 12_345)
|
||||
snapshot.localUsageEnabled = false
|
||||
#expect(snapshot.todayTokens(for: "codex", at: scannedDay, calendar: shanghai) == nil)
|
||||
let hidden = try JSONDecoder().decode(WidgetSnapshot.self, from: JSONEncoder().encode(snapshot))
|
||||
#expect(hidden.todayTokens(for: "codex", at: scannedDay, calendar: shanghai) == nil)
|
||||
snapshot.localUsageEnabled = true
|
||||
snapshot.aiEnabled = false
|
||||
#expect(snapshot.todayTokens(for: "codex", at: scannedDay, calendar: shanghai) == nil)
|
||||
}
|
||||
|
||||
@Test func oldWidgetSnapshotDecodesWithoutDailyTokens() throws {
|
||||
let legacy = Data(#"{"aiEnabled":true,"quotas":[],"publicIPEnabled":false,"addresses":[],"language":"system","calendarFirstWeekday":2,"showsLunar":true}"#.utf8)
|
||||
let legacy = Data(#"{"aiEnabled":true,"quotaEnabled":false,"quotas":[{"provider":"codex","kind":"weekly","remainingPercent":50,"resetsAt":null,"fetchedAt":0,"isStale":false}],"publicIPEnabled":false,"addresses":[],"language":"system","calendarFirstWeekday":2,"showsLunar":true}"#.utf8)
|
||||
let snapshot = try JSONDecoder().decode(WidgetSnapshot.self, from: legacy)
|
||||
#expect(snapshot.todayTokens(for: "codex") == nil)
|
||||
#expect(snapshot.currentQuotas().count == 1)
|
||||
#expect(snapshot.localUsageEnabled == nil)
|
||||
}
|
||||
|
||||
@Test func onlyWidgetsWhoseDisplayedDataChangedAreReloaded() {
|
||||
@@ -128,6 +135,11 @@ import WidgetData
|
||||
tokensChanged.dailyTokens = [.init(provider: "codex", day: now, tokens: 11)]
|
||||
#expect(tokensChanged.changedWidgetKinds(from: base) == [WidgetKind.todayTokens])
|
||||
|
||||
var localDisabled = base
|
||||
localDisabled.localUsageEnabled = false
|
||||
#expect(localDisabled.todayTokens(for: "codex", at: now) == nil)
|
||||
#expect(localDisabled.changedWidgetKinds(from: base) == [WidgetKind.todayTokens])
|
||||
|
||||
var ipDisabled = base
|
||||
ipDisabled.publicIPEnabled = false
|
||||
#expect(ipDisabled.changedWidgetKinds(from: base) == [WidgetKind.ipPurity, WidgetKind.publicIP])
|
||||
|
||||
@@ -393,6 +393,78 @@ private actor GatedUsageProvider: AIUsageProvider {
|
||||
#expect(AppSettings(defaults: defaults).aiUsageSources.isEmpty)
|
||||
}
|
||||
|
||||
@Test func localUsageCanBeDisabledWithoutStoppingSubscriptionQuota() async {
|
||||
let defaults = defaultsForAIUsage()
|
||||
// 旧版曾保存独立的额度开关;移除后不能让旧值继续阻止自动检测。
|
||||
defaults.set(false, forKey: "aiUsageShowsQuota")
|
||||
let settings = AppSettings(defaults: defaults)
|
||||
#expect(settings.aiUsageShowsLocalUsage)
|
||||
settings.aiUsageEnabled = true
|
||||
settings.aiUsageShowsLocalUsage = false
|
||||
#expect(!AppSettings(defaults: defaults).aiUsageShowsLocalUsage)
|
||||
|
||||
let local = StubUsageProvider([.success(usageSnapshot(tokens: 42))])
|
||||
let quota = AIQuotaSnapshot(provider: .codex,
|
||||
windows: [AIQuotaWindow(kind: .weekly, usedPercent: 40, resetsAt: nil)], fetchedAt: Date())
|
||||
let remote = StubQuotaProvider(results: [.success(quota), .success(quota)])
|
||||
let controller = AIUsageController(settings: settings, providers: [local], quotaProviders: [remote])
|
||||
|
||||
await controller.refresh()
|
||||
#expect(await local.count() == 0)
|
||||
#expect(await remote.fetchCount() == 1)
|
||||
#expect(controller.localReport(for: nil) == nil)
|
||||
#expect(controller.todayTokens == nil)
|
||||
#expect(controller.quotaState(for: .codex).snapshot == quota)
|
||||
|
||||
settings.aiUsageShowsLocalUsage = true
|
||||
await controller.refresh()
|
||||
#expect(await local.count() == 1)
|
||||
#expect(await remote.fetchCount() == 2)
|
||||
#expect(controller.todayTokens == 42)
|
||||
|
||||
settings.aiUsageShowsLocalUsage = false
|
||||
#expect(controller.todayTokens == nil)
|
||||
#expect(controller.localReport(for: nil) == nil)
|
||||
#expect(controller.quotaState(for: .codex).snapshot == quota)
|
||||
}
|
||||
|
||||
@Test func missingLocalSessionLogsDoNotDisplayZeroTokens() async {
|
||||
let settings = AppSettings(defaults: defaultsForAIUsage())
|
||||
settings.aiUsageEnabled = true
|
||||
var empty = AIUsageSnapshot(provider: .codex, fetchedAt: Date())
|
||||
empty.localUsage = LocalUsageReport(rows: [], fileCount: 0)
|
||||
let quota = AIQuotaSnapshot(provider: .codex,
|
||||
windows: [AIQuotaWindow(kind: .weekly, usedPercent: 30, resetsAt: nil)],
|
||||
fetchedAt: Date(), source: .sub2api)
|
||||
let controller = AIUsageController(settings: settings,
|
||||
providers: [StubUsageProvider([.success(empty)])],
|
||||
quotaProviders: [StubQuotaProvider(results: [.success(quota)])])
|
||||
|
||||
await controller.refresh()
|
||||
|
||||
#expect(controller.localReport(for: .codex) == nil)
|
||||
#expect(controller.todayTokens == nil)
|
||||
#expect(controller.visibleQuotaState(for: .codex).snapshot == quota)
|
||||
}
|
||||
|
||||
@Test func disablingLocalUsageDropsInFlightScanResult() async {
|
||||
let settings = AppSettings(defaults: defaultsForAIUsage())
|
||||
settings.aiUsageEnabled = true
|
||||
let local = GatedUsageProvider()
|
||||
let controller = AIUsageController(settings: settings, providers: [local])
|
||||
defer { controller.stop() }
|
||||
|
||||
let inFlight = Task { await controller.refresh() }
|
||||
while await local.startCount() == 0 { await Task.yield() }
|
||||
settings.aiUsageShowsLocalUsage = false
|
||||
controller.start()
|
||||
await local.release()
|
||||
await inFlight.value
|
||||
|
||||
#expect(controller.state(for: .codex).snapshot == nil)
|
||||
#expect(controller.todayTokens == nil)
|
||||
}
|
||||
|
||||
@Test func disabledSourceIsNotFetchedOrIncludedInReports() async throws {
|
||||
let now = Date()
|
||||
let snapshot = usageSnapshot(tokens: 110, at: now)
|
||||
@@ -479,8 +551,7 @@ private actor GatedUsageProvider: AIUsageProvider {
|
||||
#expect(AppSettings(defaults: defaults).panelTab == .settingsGeneral)
|
||||
}
|
||||
|
||||
/// 模块开关控制是否读取日志,菜单栏开关只保存展示偏好;模块关闭时不应显示占位图标,
|
||||
/// 重新启用后应恢复用户原来的菜单栏选择。
|
||||
/// 菜单栏开关只保存展示偏好;模块关闭时不显示占位图标。
|
||||
@Test func menuBarPreferenceDoesNotEnableScanningAndIsRestoredWithTheModule() {
|
||||
let settings = AppSettings(defaults: defaultsForAIUsage())
|
||||
#expect(!settings.aiUsageEnabled)
|
||||
@@ -494,6 +565,9 @@ private actor GatedUsageProvider: AIUsageProvider {
|
||||
settings.aiUsageEnabled = true
|
||||
#expect(settings.orderedMenuBarItems.contains(.aiUsage))
|
||||
|
||||
settings.aiUsageShowsLocalUsage = false
|
||||
#expect(settings.orderedMenuBarItems.contains(.aiUsage))
|
||||
|
||||
settings.aiUsageEnabled = false
|
||||
#expect(settings.isEnabled(.aiUsage))
|
||||
#expect(!settings.orderedMenuBarItems.contains(.aiUsage))
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ The interface supports Simplified and Traditional Chinese, English, Japanese, Ko
|
||||
|
||||
## Data and privacy
|
||||
|
||||
System-monitoring data stays on your Mac; no XStats account is required. AI Usage & Quotas is off by default. If enabled, local statistics read session logs, while quota checks read the corresponding CLI credentials without modifying them and contact the providers directly. You may configure your own Sub2API server as a fallback quota source.
|
||||
System-monitoring data stays on your Mac; no XStats account is required. AI Usage & Quotas is off by default. Once enabled, subscription quotas are detected automatically. Local usage is shown by default and can be turned off separately to stop scanning session logs. Quota checks read the corresponding CLI credentials without modifying them and contact the providers directly. You may configure your own Sub2API server as a fallback quota source.
|
||||
|
||||
Public-IP lookups and connection probes go online only when you use those features. Update checks send the app version and a SHA-256 hash of a random installation ID; the server does not persist request IPs. WebDAV sync uses a server you configure yourself and runs only when you start it; it transfers settings, not monitoring history or credentials.
|
||||
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ XStats は CPU、GPU、メモリ、ディスク、ネットワーク、バッテ
|
||||
|
||||
## データとプライバシー
|
||||
|
||||
システムの監視データは Mac 内に保存され、XStats アカウントは不要です。AI の使用量と利用枠は初期状態では無効です。有効にすると、ローカル統計はセッションログを読み、利用枠の照会は対応する CLI のログイン情報を変更せずに読み取って各サービスへ直接問い合わせます。自分の Sub2API サーバーを代替の利用枠ソースとして設定できます。
|
||||
システムの監視データは Mac 内に保存され、XStats アカウントは不要です。AI の使用量と利用枠は初期状態では無効です。有効にするとサブスクリプション利用枠を自動検出します。ローカル使用量は既定で表示され、個別にオフにするとセッションログの読み取りが止まります。利用枠の照会は対応する CLI のログイン情報を変更せずに読み取って各サービスへ直接問い合わせます。自分の Sub2API サーバーを代替の利用枠ソースとして設定できます。
|
||||
|
||||
公開 IP の照会と接続テストは、その機能を使用するときだけ通信します。更新確認ではバージョンとランダムなインストール ID の SHA-256 を送信し、サーバーはリクエスト IP を永続保存しません。WebDAV 同期はユーザーが設定したサーバーで手動実行し、設定のみを転送します。監視履歴や認証情報は転送しません。
|
||||
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ XStats는 CPU, GPU, 메모리, 디스크, 네트워크, 배터리, 온도와 팬
|
||||
|
||||
## 데이터와 개인정보
|
||||
|
||||
시스템 모니터링 데이터는 Mac에 남으며 XStats 계정은 필요하지 않습니다. AI 사용량 및 한도는 기본적으로 꺼져 있습니다. 켜면 로컬 통계는 세션 로그를 읽고, 구독 한도 조회는 해당 CLI의 로그인 정보를 변경하지 않고 읽어 각 제공자에게 직접 요청합니다. 자신의 Sub2API 서버를 예비 한도 소스로 설정할 수 있습니다.
|
||||
시스템 모니터링 데이터는 Mac에 남으며 XStats 계정은 필요하지 않습니다. AI 사용량 및 한도는 기본적으로 꺼져 있습니다. 켜면 구독 한도를 자동으로 감지합니다. 로컬 사용량은 기본으로 표시되며 별도로 끄면 세션 로그 검사가 중지됩니다. 한도 조회는 해당 CLI의 로그인 정보를 변경하지 않고 읽어 각 제공자에게 직접 요청합니다. 자신의 Sub2API 서버를 예비 한도 소스로 설정할 수 있습니다.
|
||||
|
||||
공인 IP 조회와 연결 테스트는 해당 기능을 사용할 때만 네트워크에 접속합니다. 업데이트 확인에서는 앱 버전과 무작위 설치 ID의 SHA-256을 보내며 서버는 요청 IP를 영구 저장하지 않습니다. WebDAV 동기화는 사용자가 직접 서버를 설정하고 수동으로 실행하며, 모니터링 기록이나 인증 정보가 아닌 설정만 전송합니다.
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ XStats 在菜单栏显示 CPU、GPU、内存、磁盘、网络、电池、温度
|
||||
|
||||
## 数据与隐私
|
||||
|
||||
系统监控数据留在本机,不需要 XStats 账号。AI 用量与额度默认关闭;启用后,本地统计读取本机会话日志,订阅额度查询只读使用对应 CLI 的登录凭据并直接请求服务商。可自行配置 Sub2API 作为额度备用来源。
|
||||
系统监控数据留在本机,不需要 XStats 账号。AI 用量与额度默认关闭;启用后自动检测订阅额度,本地用量默认显示,也可单独关闭以停止扫描会话日志。额度查询只读使用对应 CLI 的登录凭据并直接请求服务商,也可自行配置 Sub2API 作为备用来源。
|
||||
|
||||
公网 IP 查询和连接探测仅在使用相关功能时联网。检查更新会发送版本号和随机安装标识的 SHA-256;服务端不持久化请求 IP。WebDAV 同步由用户自行配置服务器并手动触发,只同步设置,不上传监控历史或凭据。
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ private struct TodayTokensWidgetView: View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
WidgetHeading(title: tr("今日消耗的 Token"), symbol: "sparkles")
|
||||
if sources.isEmpty {
|
||||
WidgetEmptyState(enabled: entry.snapshot.aiEnabled)
|
||||
WidgetEmptyState(enabled: entry.snapshot.showsLocalUsage)
|
||||
} else {
|
||||
let total = sources.reduce(0) { $0 + $1.tokens }
|
||||
Text(total.formatted(.number.notation(.compactName).locale(L10n.locale)))
|
||||
|
||||
Reference in new issue
Block a user