From bfce9c6cc58486ca4ea30245870a660b8aed6263 Mon Sep 17 00:00:00 2001 From: ysicing Date: Thu, 24 Sep 2026 10:05:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(ai-usage):=20=E9=9A=90=E8=97=8F=E6=97=A0?= =?UTF-8?q?=E9=A2=9D=E5=BA=A6=E6=9D=A5=E6=BA=90=E7=9A=84=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E6=A0=8F=E5=8D=A0=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../XStatsUI/AppKit/MenuBarRenderer.swift | 37 +++---------------- .../AIUsageControllerTests.swift | 19 ++++------ 2 files changed, 14 insertions(+), 42 deletions(-) diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift index dcc610d..af9a45b 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift @@ -45,8 +45,7 @@ struct MenuBarReading { var batteryHistory: [Double] = [] /// 本机日志今日 Token 总量;扫描未开启或还没有数据时为 nil var aiTokens: Int? - /// 每个已启用来源各占一个紧凑读数;没有额度时显示占位,避免来源从菜单栏消失。 - var aiQuotaProviders: [AIProviderID] = [] + /// 只有可显示额度的来源才占用菜单栏读数;本机 Token 统计独立于订阅额度。 var aiQuotas: [MenuBarQuota] = [] var batteryCharging = false /// 附在电池项旁的蓝牙设备:电量低的那一个,或没有电池的 Mac 上电量最低的那一个 @@ -74,12 +73,10 @@ struct MenuBarReading { battery = store.battery?.level batteryHistory = battery.map { Array(repeating: $0, count: 30) } ?? [] aiTokens = model.aiUsage.todayTokens - aiQuotaProviders = model.settings.aiUsageEnabled - ? AIProviderID.allCases.filter { model.settings.aiUsageSources.contains($0) } : [] aiQuotas = model.aiUsage.visibleQuotaProviders(for: nil).compactMap { provider in let state = model.aiUsage.visibleQuotaState(for: provider) guard let snapshot = state.snapshot else { return nil } - // 已过重置时间的窗口不再代表当前额度(常见于重启后离线恢复的缓存),宁可显示占位。 + // 已过重置时间的窗口不再代表当前额度(常见于重启后离线恢复的缓存),不显示过期额度。 let now = Date() let windows = snapshot.windows.filter { $0.resetsAt.map { $0 > now } ?? true } // 周额度比短时会话额度更适合作为常驻读数;模型专属周额度仅在通用周额度缺席时使用。 @@ -151,13 +148,10 @@ struct MenuBarReading { case .battery: battery.map { tr("电池 \(Format.percent($0))") + (batteryCharging ? tr(",充电中") : "") } ?? bluetoothDevice.map { tr("蓝牙设备电量 \($0.percent)%") } - // 没有读数也要给出一行,否则菜单栏只剩一个不会解释自己的 “AI —” + // 没有订阅额度时仍解释本机 Token 读数。 case .aiUsage: if !aiQuotas.isEmpty { - aiQuotaProviders.map { provider in - guard let quota = aiQuotas.first(where: { $0.provider == provider }) else { - return "\(provider == .codex ? "Codex" : "Claude") · \(tr("暂无额度数据"))" - } + aiQuotas.map { quota in let previous = quota.isStale ? "\(tr("上次额度")) · " : "" let checked = quota.isStale ? " · \(tr("上次检查:\(quota.fetchedAt.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale)))"))" @@ -301,31 +295,12 @@ enum MenuBarRenderer { private static func aiUsageSegment(reading: MenuBarReading, style: MenuBarStyle) -> Segment { guard !reading.aiQuotas.isEmpty else { - if style == .icon, reading.aiQuotaProviders.count == 1, - let provider = reading.aiQuotaProviders.first { - return combine([aiProviderLogo(provider), - inlineValue(reading.aiTokens.map { tokenText($0) } ?? "—", - sample: "999.9M", alert: false)]) - } return textSegment(item: .aiUsage, value: reading.aiTokens.map { tokenText($0) } ?? "—", sample: "999.9M", style: style) } - let providers = reading.aiQuotaProviders.count > 1 - ? reading.aiQuotaProviders : [reading.aiQuotas[0].provider] - let status = combine(providers.map { provider in + let status = combine(reading.aiQuotas.map { quota in + let provider = quota.provider let name = provider == .codex ? "Codex" : "Claude" - guard let quota = reading.aiQuotas.first(where: { $0.provider == provider }) else { - switch style { - case .ring, .history, .line, .pie, .meter: - return aiQuotaInlineText(label: name, value: "—") - case .inline: - return inlineText(label: name, value: "—", sample: "100%", alert: false) - case .icon: - return combine([aiProviderLogo(provider), inlineValue("—", sample: "100%", alert: false)]) - case .stacked, .dot: - return aiQuotaText(label: name, value: "—") - } - } let label = "\(name) · \(quota.shortWindowName)" let value = "\(quota.remainingPercent)%" let stacked = aiQuotaText(label: name, value: value) diff --git a/Packages/XStatsKit/Tests/XStatsUITests/AIUsageControllerTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/AIUsageControllerTests.swift index 56733eb..d4f3833 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/AIUsageControllerTests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/AIUsageControllerTests.swift @@ -100,11 +100,10 @@ private actor GatedUsageProvider: AIUsageProvider { #expect(codexQuota?.window.kind == .weekly) #expect(codexQuota?.remainingPercent == 27) #expect(codexQuota?.resetText != "—") - #expect(reading.aiQuotaProviders == [.codex, .claude]) + #expect(reading.aiQuotas.map(\.provider) == [.codex, .claude]) let both = MenuBarRenderer.image(reading: reading, items: [.aiUsage], style: { _ in .stacked }, networkStyle: .dots, colorizeHighLoad: false, fahrenheit: false) var codexOnly = reading - codexOnly.aiQuotaProviders = [.codex] codexOnly.aiQuotas = reading.aiQuotas.filter { $0.provider == .codex } let single = MenuBarRenderer.image(reading: codexOnly, items: [.aiUsage], style: { _ in .stacked }, networkStyle: .dots, colorizeHighLoad: false, fahrenheit: false) @@ -112,7 +111,6 @@ private actor GatedUsageProvider: AIUsageProvider { let codexIcon = MenuBarRenderer.image(reading: codexOnly, items: [.aiUsage], style: { _ in .icon }, networkStyle: .dots, colorizeHighLoad: false, fahrenheit: false) var claudeOnly = reading - claudeOnly.aiQuotaProviders = [.claude] claudeOnly.aiQuotas = reading.aiQuotas.filter { $0.provider == .claude } let claudeIcon = MenuBarRenderer.image(reading: claudeOnly, items: [.aiUsage], style: { _ in .icon }, networkStyle: .dots, colorizeHighLoad: false, fahrenheit: false) @@ -134,7 +132,7 @@ private actor GatedUsageProvider: AIUsageProvider { #expect(!tooltip.contains("5%")) } - @Test func menuBarNamesAnEnabledSourceWithoutQuotaBesideTheAvailableOne() async { + @Test func menuBarOmitsSourceWithoutQuotaBesideTheAvailableOne() async { let settings = AppSettings(defaults: defaultsForAIUsage()) settings.aiUsageEnabled = true let codex = AIQuotaSnapshot(provider: .codex, windows: [ @@ -147,10 +145,8 @@ private actor GatedUsageProvider: AIUsageProvider { await model.aiUsage.refresh() let reading = MenuBarReading(model: model) - #expect(reading.aiQuotas.count == 1) - let claudeLine = reading.tooltip(items: [.aiUsage], fahrenheit: false) - .split(separator: "\n").first { $0.hasPrefix("Claude · ") } - #expect(claudeLine?.contains("%") == false) + #expect(reading.aiQuotas.map(\.provider) == [.codex]) + #expect(!reading.tooltip(items: [.aiUsage], fahrenheit: false).contains("Claude")) let image = MenuBarRenderer.image(reading: reading, items: [.aiUsage], style: { _ in .stacked }, networkStyle: .dots, colorizeHighLoad: false, fahrenheit: false) #expect(image.size.width > 0) @@ -195,8 +191,7 @@ private actor GatedUsageProvider: AIUsageProvider { #expect(reading.aiQuotas.first?.window.kind == .session) #expect(reading.aiQuotas.first?.remainingPercent == 70) let tooltip = reading.tooltip(items: [.aiUsage], fahrenheit: false) - let claudeLine = tooltip.split(separator: "\n").first { $0.hasPrefix("Claude · ") } - #expect(claudeLine?.contains("%") == false, "tooltip: \(tooltip)") + #expect(!tooltip.contains("Claude"), "tooltip: \(tooltip)") #expect(!tooltip.contains("5%"), "tooltip: \(tooltip)") } @@ -208,7 +203,9 @@ private actor GatedUsageProvider: AIUsageProvider { aiQuotaProviders: [StubQuotaProvider(results: [.failure(.notConfigured)])]) await model.aiUsage.refresh() - let tooltip = MenuBarReading(model: model).tooltip(items: [.aiUsage], fahrenheit: false) + let reading = MenuBarReading(model: model) + #expect(reading.aiQuotas.isEmpty) + let tooltip = reading.tooltip(items: [.aiUsage], fahrenheit: false) #expect(tooltip.contains("42 Tokens")) #expect(!tooltip.contains("剩余")) }