feat(ai-usage): 并列显示双来源额度并整理弹窗布局
This commit is contained in:
1 parent
cb2b57eb79
commit
d413e34fe2
10 files changed
+157
-42
No files matched your search
+8
-5
@@ -246,16 +246,19 @@ signs in to its configured server, reads the account record to verify its platfo
|
||||
If the detailed endpoint is unavailable, the account's `extra` fields provide coarser five-hour and
|
||||
weekly windows. Claude may also expose Sonnet and Fable weekly windows. Credentials and quota results
|
||||
are never sent to XStats servers; the UI marks fallback results as Sub2API.
|
||||
When a subscription snapshot exists, the menu bar AI reading shows the weekly window (or the
|
||||
five-hour window if weekly is unavailable), its remaining percentage, and a compact reset time.
|
||||
With multiple providers it displays the one with less remaining quota; the tooltip lists both.
|
||||
Without a subscription snapshot it keeps the local daily Token reading.
|
||||
When one provider has a subscription snapshot, the menu bar AI reading shows its weekly window
|
||||
(or five-hour window if weekly is unavailable), remaining percentage and compact reset time.
|
||||
When both sources are enabled and either has quota data, the item shows a compact named reading
|
||||
for each source; one without quota data shows a dash. The tooltip lists reset times and missing
|
||||
data by source. Without any subscription snapshot it keeps the local daily Token reading.
|
||||
The quota detail card presents remaining allowance (not used allowance) and, when the server
|
||||
provides a reset date, a separate bar for elapsed time in that five-hour or seven-day window.
|
||||
Allowance turns red as it runs low; the reset bar turns green as reset approaches and updates
|
||||
while the view is open.
|
||||
The UI follows CC Switch's filter/summary/trend/model-table organization, implemented in SwiftUI.
|
||||
The desktop toolbar groups source controls and settings. Token totals use compact notation with
|
||||
The desktop toolbar groups source controls and actions. In the compact menu-bar popover, its header
|
||||
owns refresh and AI Usage settings; the content shows a source switch only when multiple sources
|
||||
are enabled. Token totals use compact notation with
|
||||
exact hover/VoiceOver values. Cache details are
|
||||
disclosed on demand and local settings live in a native popover. Since source data is aggregated
|
||||
by day, all three activity modes cover the same trailing 365 days: daily uses one cell per day;
|
||||
|
||||
@@ -46,7 +46,8 @@ struct MenuBarReading {
|
||||
var batteryHistory: [Double] = []
|
||||
/// 本机日志今日 Token 总量;扫描未开启或还没有数据时为 nil
|
||||
var aiTokens: Int?
|
||||
/// 每个已登录来源选一个订阅窗口;菜单栏主读数取剩余最少者,完整列表放在悬停提示中。
|
||||
/// 每个已启用来源各占一个紧凑读数;没有额度时显示占位,避免来源从菜单栏消失。
|
||||
var aiQuotaProviders: [AIProviderID] = []
|
||||
var aiQuotas: [MenuBarQuota] = []
|
||||
var primaryAIQuota: MenuBarQuota? { aiQuotas.min { $0.remainingPercent < $1.remainingPercent } }
|
||||
var batteryCharging = false
|
||||
@@ -75,6 +76,8 @@ 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
|
||||
guard let snapshot = model.aiUsage.visibleQuotaState(for: provider).snapshot else { return nil }
|
||||
let windows = snapshot.windows
|
||||
@@ -149,8 +152,11 @@ struct MenuBarReading {
|
||||
// 没有读数也要给出一行,否则菜单栏只剩一个不会解释自己的 “AI —”
|
||||
case .aiUsage:
|
||||
if !aiQuotas.isEmpty {
|
||||
aiQuotas.map { quota in
|
||||
"\(quota.sourceName)\(quota.source == .sub2api ? " (Sub2API)" : "") · \(quota.shortWindowName) · \(tr("剩余")) \(quota.remainingPercent)% · \(tr("重置:")) \(quota.resetText)"
|
||||
aiQuotaProviders.map { provider in
|
||||
guard let quota = aiQuotas.first(where: { $0.provider == provider }) else {
|
||||
return "\(provider == .codex ? "Codex" : "Claude") · \(tr("暂无额度数据"))"
|
||||
}
|
||||
return "\(quota.sourceName)\(quota.source == .sub2api ? " (Sub2API)" : "") · \(quota.shortWindowName) · \(tr("剩余")) \(quota.remainingPercent)% · \(tr("重置:")) \(quota.resetText)"
|
||||
}.joined(separator: "\n")
|
||||
} else {
|
||||
aiTokens.map { "AI · \(UsageNumber.exact($0)) Tokens" } ?? ("AI · " + tr("暂无本机用量数据"))
|
||||
@@ -280,6 +286,18 @@ enum MenuBarRenderer {
|
||||
case .battery:
|
||||
return batterySegment(reading: reading, style: style, colorizeHighLoad: colorizeHighLoad)
|
||||
case .aiUsage:
|
||||
if !reading.aiQuotas.isEmpty && reading.aiQuotaProviders.count > 1 {
|
||||
let statuses = reading.aiQuotaProviders.map { provider -> Segment in
|
||||
let name = provider == .codex ? "Codex" : "Claude"
|
||||
guard let quota = reading.aiQuotas.first(where: { $0.provider == provider }) else {
|
||||
return stackedText(label: name, value: "—", sample: "100%", alert: false)
|
||||
}
|
||||
return stackedText(label: "\(name) · \(quota.shortWindowName)",
|
||||
value: "\(quota.remainingPercent)%", sample: "100%", alert: false)
|
||||
}
|
||||
let status = combine(statuses, gap: DS.Space.s2)
|
||||
return style == .icon ? combine([symbolSegment(item.symbol), status]) : status
|
||||
}
|
||||
if let quota = reading.primaryAIQuota {
|
||||
let status = stackedText(label: "\(quota.shortWindowName) · \(quota.resetText)",
|
||||
value: "\(quota.sourceName) \(quota.remainingPercent)%",
|
||||
|
||||
@@ -17,7 +17,7 @@ private struct LocalUsageContent: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@State private var selectedModel = ""
|
||||
@State private var source = "all"
|
||||
@State private var settingsOpen = false
|
||||
@State private var emptySettingsOpen = false
|
||||
@State private var activityMode = UsageActivityMode.daily
|
||||
|
||||
private var enabledProviders: [AIProviderID] {
|
||||
@@ -56,7 +56,9 @@ private struct LocalUsageContent: View {
|
||||
} else if model.settings.aiUsageSources.isEmpty {
|
||||
Card {
|
||||
Text(tr("数据来源") + " · " + tr("尚未启用")).dsFont(.sm).foregroundStyle(DS.Palette.textSecondary)
|
||||
Button(tr("设置")) { settingsOpen = true }.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
Button(tr("设置")) { emptySettingsOpen = true }
|
||||
.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
.popover(isPresented: $emptySettingsOpen) { AIUsageSettingsPanel() }
|
||||
}
|
||||
} else if let report = model.aiUsage.localReport(for: provider) {
|
||||
let rows = report.summary(mode: activityMode, model: selectedModel.isEmpty ? nil : selectedModel)
|
||||
@@ -84,8 +86,8 @@ private struct LocalUsageContent: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var toolbar: some View {
|
||||
VStack(spacing: DS.Space.s2) {
|
||||
@ViewBuilder private var toolbar: some View {
|
||||
if !compact || (model.settings.aiUsageEnabled && enabledProviders.count > 1) {
|
||||
HStack(spacing: DS.Space.s2) {
|
||||
if model.settings.aiUsageEnabled && enabledProviders.count > 1 {
|
||||
UsageChoices(selection: $source, options: sourceOptions,
|
||||
@@ -93,28 +95,15 @@ private struct LocalUsageContent: View {
|
||||
.frame(maxWidth: compact ? .infinity : 260)
|
||||
.onChange(of: source) { _, _ in selectedModel = "" }
|
||||
}
|
||||
Spacer(minLength: DS.Space.s4)
|
||||
refreshButton
|
||||
MiniIconButton(systemName: "gearshape", help: tr("设置")) { settingsOpen.toggle() }
|
||||
.popover(isPresented: $settingsOpen) {
|
||||
ScrollView { UsageSettings().padding(DS.Space.s4) }
|
||||
.frame(width: 390, height: 520)
|
||||
}
|
||||
if !compact {
|
||||
Spacer(minLength: DS.Space.s4)
|
||||
AIUsageRefreshButton()
|
||||
AIUsageSettingsButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var refreshButton: some View {
|
||||
ZStack {
|
||||
MiniIconButton(systemName: "arrow.clockwise", help: tr("立即刷新")) {
|
||||
Task { await model.aiUsage.refresh() }
|
||||
}
|
||||
.opacity(model.aiUsage.isRefreshing ? 0 : 1)
|
||||
.disabled(!model.settings.aiUsageEnabled || model.settings.aiUsageSources.isEmpty || model.aiUsage.isRefreshing)
|
||||
if model.aiUsage.isRefreshing { ProgressView().controlSize(.small).accessibilityLabel(tr("正在刷新")) }
|
||||
}.frame(width: DS.Size.controlHeight, height: DS.Size.controlHeight)
|
||||
}
|
||||
|
||||
private var quotaSection: some View {
|
||||
Card(spacing: DS.Space.s3) {
|
||||
let visibleProviders = model.aiUsage.visibleQuotaProviders(for: provider)
|
||||
@@ -126,18 +115,25 @@ private struct LocalUsageContent: View {
|
||||
.help(tr("服务端用量 · 与本机 Token 统计不同"))
|
||||
.accessibilityLabel(tr("服务端用量 · 与本机 Token 统计不同"))
|
||||
Spacer()
|
||||
if let provider, model.aiUsage.visibleQuotaState(for: provider).snapshot?.source == .sub2api {
|
||||
Text("Sub2API").dsFont(.xs, weight: .medium)
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
}
|
||||
ForEach(visibleProviders) { id in
|
||||
let state = model.aiUsage.visibleQuotaState(for: id)
|
||||
VStack(alignment: .leading, spacing: DS.Space.s3) {
|
||||
if provider == nil {
|
||||
Text(id == .codex ? "Codex" : "Claude Code").dsFont(.xs, weight: .semibold)
|
||||
HStack {
|
||||
Text(id == .codex ? "Codex" : "Claude Code").dsFont(.xs, weight: .semibold)
|
||||
Spacer()
|
||||
if state.snapshot?.source == .sub2api {
|
||||
Text("Sub2API").dsFont(.xs, weight: .medium)
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let snapshot = state.snapshot {
|
||||
if snapshot.source == .sub2api {
|
||||
Text("Sub2API").dsFont(.xs, weight: .medium)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
ForEach(snapshot.windows) { window in
|
||||
VStack(alignment: .leading, spacing: DS.Space.s1) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
@@ -169,6 +165,17 @@ private struct LocalUsageContent: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sub2API 的 Fable 窗口可能为 null;显示缺失状态,不推断为 0% 已用。
|
||||
if id == .claude, snapshot.source == .sub2api,
|
||||
snapshot.window(.fableWeekly) == nil {
|
||||
HStack {
|
||||
Text(quotaTitle(.fableWeekly))
|
||||
Spacer()
|
||||
Text(tr("暂无额度数据"))
|
||||
}
|
||||
.dsFont(.xs)
|
||||
.foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
if state.failure != nil {
|
||||
Text(tr("查询失败,显示上次额度"))
|
||||
.dsFont(.xs).foregroundStyle(DS.Palette.warning)
|
||||
@@ -603,6 +610,38 @@ private struct ActivityCell: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct AIUsageRefreshButton: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
MiniIconButton(systemName: "arrow.clockwise", help: tr("立即刷新")) {
|
||||
Task { await model.aiUsage.refresh() }
|
||||
}
|
||||
.opacity(model.aiUsage.isRefreshing ? 0 : 1)
|
||||
.disabled(!model.settings.aiUsageEnabled || model.settings.aiUsageSources.isEmpty || model.aiUsage.isRefreshing)
|
||||
if model.aiUsage.isRefreshing { ProgressView().controlSize(.small).accessibilityLabel(tr("正在刷新")) }
|
||||
}
|
||||
.frame(width: DS.Size.controlHeight, height: DS.Size.controlHeight)
|
||||
}
|
||||
}
|
||||
|
||||
struct AIUsageSettingsButton: View {
|
||||
@State private var isOpen = false
|
||||
|
||||
var body: some View {
|
||||
MiniIconButton(systemName: "gearshape", help: tr("AI 使用统计") + " · " + tr("设置")) { isOpen.toggle() }
|
||||
.popover(isPresented: $isOpen) { AIUsageSettingsPanel() }
|
||||
}
|
||||
}
|
||||
|
||||
private struct AIUsageSettingsPanel: View {
|
||||
var body: some View {
|
||||
ScrollView { UsageSettings().padding(DS.Space.s4) }
|
||||
.frame(width: 390, height: 520)
|
||||
}
|
||||
}
|
||||
|
||||
private struct UsageSettings: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
var body: some View {
|
||||
|
||||
@@ -81,10 +81,16 @@ struct PopoverHeader: View {
|
||||
.foregroundStyle(DS.Palette.textPrimary)
|
||||
Spacer(minLength: DS.Space.s2)
|
||||
if item == .memory { PurgeMemoryButton() }
|
||||
// 左边的按钮用该指标自己的图标,点进主窗口里它的页面;右边的齿轮进总设置
|
||||
// 左边的按钮用该指标自己的图标,点进主窗口里它的页面。
|
||||
let page = PanelTab(item: item)
|
||||
MiniIconButton(systemName: page.symbol, help: tr("在主窗口打开“\(page.title)”")) { model.openMainWindow(page) }
|
||||
MiniIconButton(systemName: "gearshape", help: tr("设置")) { model.openSettings() }
|
||||
MiniIconButton(systemName: item == .aiUsage ? "arrow.up.right.square" : page.symbol,
|
||||
help: tr("在主窗口打开“\(page.title)”")) { model.openMainWindow(page) }
|
||||
if item == .aiUsage {
|
||||
AIUsageRefreshButton()
|
||||
AIUsageSettingsButton()
|
||||
} else {
|
||||
MiniIconButton(systemName: "gearshape", help: tr("设置")) { model.openSettings() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +211,18 @@ private final class InMemorySub2APISecretStore: Sub2APISecretStore {
|
||||
#expect(await fallbackHTTP.requests().count == 3)
|
||||
}
|
||||
|
||||
@Test func detailedClaudeFableWindowIsUsedOnlyWhenServerReturnsAValue() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
let present = Data(#"{"code":0,"data":{"five_hour":{"utilization":12},"seven_day_fable":{"utilization":37,"remaining_seconds":86400}}}"#.utf8)
|
||||
let missing = Data(#"{"code":0,"data":{"five_hour":{"utilization":12},"seven_day_fable":null}}"#.utf8)
|
||||
|
||||
let fable = try Sub2APIQuotaClient.parseUsage(present, now: now, provider: .claude)
|
||||
#expect(fable.window(.fableWeekly)?.remainingPercent == 63)
|
||||
#expect(fable.window(.fableWeekly)?.resetsAt == now.addingTimeInterval(86400))
|
||||
let unavailable = try Sub2APIQuotaClient.parseUsage(missing, now: now, provider: .claude)
|
||||
#expect(unavailable.window(.fableWeekly) == nil)
|
||||
}
|
||||
|
||||
@Test func mismatchedAccountPlatformIsRejectedBeforeUsageQuery() async throws {
|
||||
let http = StubSub2APIHTTPClient([
|
||||
(200, #"{"code":0,"data":{"access_token":"short-lived"}}"#),
|
||||
|
||||
@@ -78,7 +78,7 @@ private actor GatedUsageProvider: AIUsageProvider {
|
||||
|
||||
@MainActor
|
||||
@Suite struct AIUsageControllerTests {
|
||||
@Test func menuBarPrefersWeeklyRemainingQuotaAndListsBothSourcesInTooltip() async {
|
||||
@Test func menuBarShowsBothSubscriptionSourcesAndKeepsWeeklySummary() async {
|
||||
let settings = AppSettings(defaults: defaultsForAIUsage())
|
||||
settings.aiUsageEnabled = true
|
||||
let reset = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
@@ -100,6 +100,15 @@ private actor GatedUsageProvider: AIUsageProvider {
|
||||
#expect(reading.primaryAIQuota?.window.kind == .weekly)
|
||||
#expect(reading.primaryAIQuota?.remainingPercent == 27)
|
||||
#expect(reading.primaryAIQuota?.resetText != "—")
|
||||
#expect(reading.aiQuotaProviders == [.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)
|
||||
#expect(both.size.width > single.size.width)
|
||||
let tooltip = reading.tooltip(items: [.aiUsage], fahrenheit: false)
|
||||
#expect(tooltip.contains("Codex"))
|
||||
#expect(tooltip.contains("27%"))
|
||||
@@ -108,19 +117,39 @@ private actor GatedUsageProvider: AIUsageProvider {
|
||||
#expect(!tooltip.contains("5%"))
|
||||
}
|
||||
|
||||
@Test func menuBarNamesAnEnabledSourceWithoutQuotaBesideTheAvailableOne() async {
|
||||
let settings = AppSettings(defaults: defaultsForAIUsage())
|
||||
settings.aiUsageEnabled = true
|
||||
let codex = AIQuotaSnapshot(provider: .codex, windows: [
|
||||
AIQuotaWindow(kind: .weekly, usedPercent: 50, resetsAt: nil),
|
||||
], fetchedAt: Date())
|
||||
let model = AppModel(settings: settings, historyURL: nil, aiUsageProviders: [], aiQuotaProviders: [
|
||||
StubQuotaProvider(results: [.success(codex)]),
|
||||
StubQuotaProvider(id: .claude, results: [.failure(.notConfigured)]),
|
||||
])
|
||||
await model.aiUsage.refresh()
|
||||
|
||||
let reading = MenuBarReading(model: model)
|
||||
#expect(reading.aiQuotas.count == 1)
|
||||
#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)
|
||||
}
|
||||
|
||||
@Test func menuBarLabelsManualQuotaAndOptionalFastWindow() async {
|
||||
let settings = AppSettings(defaults: defaultsForAIUsage())
|
||||
settings.aiUsageEnabled = true
|
||||
let snapshot = AIQuotaSnapshot(provider: .codex, windows: [
|
||||
let snapshot = AIQuotaSnapshot(provider: .claude, windows: [
|
||||
AIQuotaWindow(kind: .fableWeekly, usedPercent: 40, resetsAt: nil),
|
||||
], fetchedAt: Date(), source: .sub2api)
|
||||
let model = AppModel(settings: settings, historyURL: nil, aiUsageProviders: [],
|
||||
aiQuotaProviders: [StubQuotaProvider(results: [.success(snapshot)])])
|
||||
aiQuotaProviders: [StubQuotaProvider(id: .claude, results: [.success(snapshot)])])
|
||||
await model.aiUsage.refresh()
|
||||
|
||||
let reading = MenuBarReading(model: model)
|
||||
#expect(reading.primaryAIQuota?.shortWindowName == "7d F")
|
||||
#expect(reading.tooltip(items: [.aiUsage], fahrenheit: false).contains("Codex (Sub2API)"))
|
||||
#expect(reading.tooltip(items: [.aiUsage], fahrenheit: false).contains("Claude (Sub2API)"))
|
||||
}
|
||||
|
||||
@Test func menuBarFallsBackToLocalTokensWhenSubscriptionIsUnavailable() async {
|
||||
|
||||
@@ -119,6 +119,7 @@ Choose which sections each popover shows in Settings. `Esc` closes it.
|
||||
- **GPU**, **Temperature & fans.** History, sensor groups, fan speeds and quick modes.
|
||||
- **Battery.** Charge level, time remaining, adapter wattage and battery temperature; a 24-hour charge curve; power draw; health and cycle count; the batteries of connected Bluetooth devices (AirPods, Magic Keyboard / Mouse / Trackpad). Macs without a battery show the Bluetooth devices only.
|
||||
- **AI usage**: reads local Codex / Claude Code session logs for token totals, cache hit rate, daily trends and model ranking. Defaults to a one-year activity heatmap; switch between daily, weekly and cumulative year views and filter by model. SQLite persists parser checkpoints and statistics across restarts; appended logs are read incrementally. When enabled, it also reads local CLI login credentials and directly queries five-hour and weekly quotas.
|
||||
When both sources are enabled and at least one has quota data, the menu bar AI item shows Codex and Claude separately; a source without quota data shows a dash, and the tooltip lists reset times.
|
||||
|
||||
<p align="center">
|
||||
<img src="Assets/readme/popover-cpu-light.png" width="32%" alt="CPU popover">
|
||||
@@ -228,6 +229,7 @@ Every feature that touches the network can be turned off. Public IP and connecti
|
||||
**AI usage** is off by default. When enabled, local token statistics read only Codex / Claude Code session logs; quota checks read CLI login credentials and contact `chatgpt.com` / `api.anthropic.com` directly for five-hour and weekly usage. Tokens go only to the corresponding provider and are not saved in XStats preferences or its statistics database. Session logs are not sent with quota requests; quota failures do not affect local statistics.
|
||||
|
||||
If local Codex or Claude Code sign-in cannot provide quota data, you can configure a separate Sub2API HTTPS address, admin email and password, and account ID for each source in AI Usage settings. Each fallback is used only when that source's automatic lookup fails, and XStats checks the account platform before displaying its quota. Background requests use `force=false` to read cached data without triggering an active probe. Five-hour and seven-day windows are supported, along with Sonnet and Fable weekly windows when a Claude account provides them. The two admin passwords stay separately in this Mac's Keychain; connection settings stay local and are excluded from WebDAV backups. Sign-in sends the admin email and password to the configured Sub2API server; quota data is never uploaded to XStats servers.
|
||||
When Sub2API provides no Fable value, the page says that quota data is unavailable instead of treating the missing value as 0% used.
|
||||
|
||||
WebDAV sync sends only preferences when you configure a server and start a transfer manually. It excludes monitoring data, history and WebDAV credentials.
|
||||
|
||||
|
||||
@@ -170,7 +170,9 @@ XStats の終了時やクラッシュ時には自動制御へ戻り、蓋を閉
|
||||
|
||||
指標はローカルのカーネル、IOKit、SMC から取得し、設定はアプリの UserDefaults に保存します。監視指標、履歴、ハードウェアのシリアル番号、プロセス一覧はアップロードしません。
|
||||
AI 使用統計は初期状態で無効です。有効にすると、ローカル Codex / Claude Code ログからモデル別 Token、キャッシュヒット率、日別推移を集計し、CLI のログイン情報を読み取り専用で確認して各サービスへ直接アクセスし、5 時間・週間の利用枠も取得します。既定は過去1年の活動ヒートマップ。日別・週別・累計の3表示はいずれも過去1年を表示します。SQLite に読取位置と統計を保存し、再起動後も差分を読み取ります。ログイン情報は XStats の設定や統計 DB に保存せず、セッションログは利用枠の問い合わせに送信しません。
|
||||
両方のソースが有効で、いずれかに利用枠データがある場合、メニューバーの AI 項目に Codex と Claude を別々に表示します。データがないソースはダッシュで示し、リセット時刻はツールチップで確認できます。
|
||||
ローカルの Codex または Claude Code ログインから利用枠を取得できない場合は、AI 使用統計の設定で各ソースに別々の Sub2API HTTPS アドレス、管理者メールアドレスとパスワード、アカウント ID を登録できます。それぞれの自動取得に失敗した場合だけ対応する設定を使用し、アカウントのプラットフォームも確認します。バックグラウンド照会は `force=false` で能動的な検査を行いません。管理者パスワードはソースごとにこの Mac のキーチェーンに保存し、接続設定は WebDAV バックアップに含めません。ログイン時には設定した Sub2API サーバーへ管理者情報を送信します。
|
||||
Sub2API が Fable の数値を返さない場合、画面には利用枠データがないことを表示し、未取得の値を使用率 0% として扱いません。
|
||||
公開 IP は Cloudflare(失敗時は ipify)、IP 評価は cleanip.io に直接問い合わせます。接続テストは選択した対象へ ICMP ping を送ります。更新確認は現在のバージョンとランダムなインストール ID の SHA-256 を送信してバージョン情報を取得します。中国地域では `x-stats.china.12306.work`、その他では `xstats-apps.12306.work` を優先し、失敗時のみもう一方へ順番にフォールバックして、最初の成功後は停止します。サーバーはこのハッシュ、現在のバージョン、初回/最終確認時刻、確認回数のみを保存し、シリアル番号を保存せず、リクエスト IP も永続化しません(1 分間のメモリ内レート制限にのみ使用します)。自動更新確認を無効にすると自動送信も停止します。
|
||||
これらは設定で無効化できます。Apple Intelligence によるプロセス説明は端末内で行います。
|
||||
|
||||
|
||||
@@ -170,7 +170,9 @@ XStats를 종료하거나 앱이 비정상 종료되면 팬이 자동 모드로
|
||||
|
||||
지표는 로컬 커널, IOKit, SMC에서 읽으며 설정은 앱의 UserDefaults에 저장합니다. 모니터링 지표, 기록, 하드웨어 일련번호와 프로세스 목록은 업로드하지 않습니다.
|
||||
AI 사용 통계는 기본적으로 꺼져 있습니다. 켜면 로컬 Codex / Claude Code 로그로 모델별 토큰, 캐시 적중률, 일별 추이를 집계하고, CLI 로그인 정보를 읽기 전용으로 확인하여 각 서비스에서 5시간 및 주간 사용 한도를 직접 조회합니다. 기본값은 최근 1년 활동 히트맵입니다. SQLite에 읽기 위치와 통계를 저장하여 재시작 후에도 증분을 읽습니다. 일별·주별·누적 보기 모두 최근 1년을 표시합니다. 로그인 정보는 XStats 설정이나 통계 DB에 저장하지 않으며 세션 로그를 한도 조회 요청에 보내지 않습니다.
|
||||
두 소스가 모두 켜져 있고 하나라도 할당량 데이터가 있으면 메뉴 막대의 AI 항목에 Codex와 Claude를 각각 표시합니다. 데이터가 없는 소스는 대시로 표시하고 재설정 시각은 툴팁에 표시합니다.
|
||||
로컬 Codex 또는 Claude Code 로그인으로 할당량을 가져올 수 없다면 AI 사용 통계 설정에서 각 소스에 별도의 Sub2API HTTPS 주소, 관리자 이메일과 비밀번호, 계정 ID를 등록할 수 있습니다. 각 소스의 자동 조회가 실패할 때만 해당 예비 설정을 사용하고 계정 플랫폼도 확인합니다. 백그라운드 요청은 `force=false`로 캐시된 할당량을 읽어 능동 검사를 실행하지 않습니다. 관리자 비밀번호는 소스별로 이 Mac의 키체인에 저장하고 연결 설정은 WebDAV 백업에 포함하지 않습니다. 로그인할 때는 지정한 Sub2API 서버로 관리자 정보를 전송합니다.
|
||||
Sub2API가 Fable 수치를 반환하지 않으면 페이지에 할당량 데이터가 없다고 표시하며, 누락된 값을 사용량 0%로 취급하지 않습니다.
|
||||
공인 IP는 Cloudflare(실패 시 ipify), IP 평판은 cleanip.io에 직접 조회합니다. 연결 테스트는 선택한 대상으로 ICMP ping을 보냅니다. 업데이트 확인은 현재 버전과 무작위 설치 ID의 SHA-256을 보내고 버전 정보를 읽습니다. 중국 지역은 `x-stats.china.12306.work`, 그 외 지역은 `xstats-apps.12306.work`를 우선 사용하며, 실패한 경우에만 다른 엔드포인트로 순차 전환하고 첫 성공 후 중지합니다. 서버에는 해당 해시, 현재 버전, 최초·최근 확인 시각과 확인 횟수만 저장하며, 하드웨어 일련번호를 저장하지 않고 요청 IP도 영구 저장하지 않습니다(1분 동안의 메모리 내 속도 제한에만 사용). 자동 업데이트 확인을 끄면 자동 요청도 중지됩니다.
|
||||
이 기능들은 설정에서 끌 수 있습니다. Apple Intelligence 프로세스 설명은 기기 안에서 처리됩니다.
|
||||
|
||||
|
||||
@@ -145,6 +145,7 @@ XStats 暂无官网。自动更新与现有 GeoIP 服务保持不变;账号登
|
||||
- **GPU**、**温度与风扇**:使用历史、各组温度、风扇转速与快捷模式。
|
||||
- **电池**:电量与剩余 / 充满时间、适配器功率与电池温度;最近 24 小时电量曲线;功耗;健康度与循环次数;已连接蓝牙设备的电量(AirPods、妙控键盘 / 鼠标 / 触控板)。没有电池的 Mac 只显示蓝牙设备。
|
||||
- **AI 使用统计**:只读本机 Codex / Claude Code 会话日志,按模型统计输入、输出、缓存 Token;默认显示近一年活动热力图,提供每日、每周、累计三种全年活动视图及模型筛选;SQLite 保存扫描游标和统计结果,重启可复用,日志追加时只读新增部分。启用后还会自动使用本机 CLI 登录凭据直接查询两家服务的 5 小时及每周额度。
|
||||
两个来源都启用且至少一个有额度时,菜单栏 AI 项会分别显示 Codex 和 Claude 的剩余比例;没有额度的来源显示占位,悬停提示列出重置时间。
|
||||
|
||||
<p align="center">
|
||||
<img src="Assets/readme/popover-cpu-light.png" width="32%" alt="CPU 详情弹窗">
|
||||
@@ -230,6 +231,7 @@ XStats 暂无官网。自动更新与现有 GeoIP 服务保持不变;账号登
|
||||
**AI 使用统计**默认关闭。启用后,本机 Token 统计只读取 Codex / Claude Code 会话日志;额度部分会只读检查 CLI 登录凭据,直接向 `chatgpt.com` / `api.anthropic.com` 查询 5 小时及每周用量。登录令牌只发送到对应服务,不保存到 XStats 设置或统计数据库,日志内容不随请求发送。额度接口可能变化或限流,失败时不影响本机统计。
|
||||
|
||||
若无法通过本机 Codex 或 Claude Code 登录获取额度,可在 AI 使用统计设置中分别为这两个来源配置自己的 Sub2API HTTPS 地址、管理员邮箱与密码、账号 ID。每个来源仅在自身自动查询失败时使用对应的备选配置,并核对账号平台;后台请求使用 `force=false` 读取额度,不触发主动探测。可显示 5 小时、7 天,以及 Claude 账号提供时的 Sonnet / Fable 周窗口。两份管理员密码分别保存在本机钥匙串,连接信息只留在本机设置中,不进入 WebDAV 备份。登录请求会把管理员邮箱和密码发送到你配置的 Sub2API 服务器,额度数据不会上传到 XStats 服务器。
|
||||
Sub2API 未提供 Fable 数值时,页面显示“暂无额度数据”,不会把缺失值当成 0% 已用。
|
||||
|
||||
WebDAV 仅在你自行配置服务器并手动同步时传输偏好设置,不上传监控数据、历史记录或 WebDAV 凭据。
|
||||
|
||||
|
||||
Reference in new issue
Block a user