chore: add TokenLens sources and ignore rules
This commit is contained in:
93
NativeTokenLens/Sources/TokenLens/AppSettings.swift
Normal file
93
NativeTokenLens/Sources/TokenLens/AppSettings.swift
Normal file
@@ -0,0 +1,93 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
enum AutoCollapseDelay: Int, CaseIterable, Identifiable {
|
||||
case three = 3
|
||||
case five = 5
|
||||
case ten = 10
|
||||
|
||||
var id: Int { rawValue }
|
||||
var label: String { "\(rawValue) 秒" }
|
||||
}
|
||||
|
||||
enum FloatingAppearance: String, CaseIterable, Identifiable {
|
||||
case system = "跟随主题"
|
||||
case light = "亮色"
|
||||
case dark = "暗色"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var colorScheme: ColorScheme? {
|
||||
switch self {
|
||||
case .system:
|
||||
return nil
|
||||
case .light:
|
||||
return .light
|
||||
case .dark:
|
||||
return .dark
|
||||
}
|
||||
}
|
||||
|
||||
var nsAppearance: NSAppearance? {
|
||||
switch self {
|
||||
case .system:
|
||||
return nil
|
||||
case .light:
|
||||
return NSAppearance(named: .aqua)
|
||||
case .dark:
|
||||
return NSAppearance(named: .darkAqua)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AppSettings: ObservableObject {
|
||||
static let shared = AppSettings()
|
||||
|
||||
@Published var showFloatingWindow: Bool {
|
||||
didSet { UserDefaults.standard.set(showFloatingWindow, forKey: Self.showFloatingWindowKey) }
|
||||
}
|
||||
|
||||
@Published var showStatusBarIndicator: Bool {
|
||||
didSet { UserDefaults.standard.set(showStatusBarIndicator, forKey: Self.showStatusBarIndicatorKey) }
|
||||
}
|
||||
|
||||
@Published var autoCollapseSeconds: Int {
|
||||
didSet { UserDefaults.standard.set(autoCollapseSeconds, forKey: Self.autoCollapseSecondsKey) }
|
||||
}
|
||||
|
||||
@Published var floatingAppearance: FloatingAppearance {
|
||||
didSet { UserDefaults.standard.set(floatingAppearance.rawValue, forKey: Self.floatingAppearanceKey) }
|
||||
}
|
||||
|
||||
private static let showFloatingWindowKey = "showFloatingWindow"
|
||||
private static let showStatusBarIndicatorKey = "showStatusBarIndicator"
|
||||
private static let autoCollapseSecondsKey = "autoCollapseSeconds"
|
||||
private static let floatingAppearanceKey = "floatingAppearance"
|
||||
|
||||
private init() {
|
||||
let defaults = UserDefaults.standard
|
||||
if defaults.object(forKey: Self.showFloatingWindowKey) == nil {
|
||||
defaults.set(true, forKey: Self.showFloatingWindowKey)
|
||||
}
|
||||
if defaults.object(forKey: Self.showStatusBarIndicatorKey) == nil {
|
||||
defaults.set(true, forKey: Self.showStatusBarIndicatorKey)
|
||||
}
|
||||
if defaults.object(forKey: Self.autoCollapseSecondsKey) == nil {
|
||||
defaults.set(AutoCollapseDelay.five.rawValue, forKey: Self.autoCollapseSecondsKey)
|
||||
}
|
||||
if defaults.object(forKey: Self.floatingAppearanceKey) == nil {
|
||||
defaults.set(FloatingAppearance.system.rawValue, forKey: Self.floatingAppearanceKey)
|
||||
}
|
||||
showFloatingWindow = defaults.bool(forKey: Self.showFloatingWindowKey)
|
||||
showStatusBarIndicator = defaults.bool(forKey: Self.showStatusBarIndicatorKey)
|
||||
let collapseSeconds = defaults.integer(forKey: Self.autoCollapseSecondsKey)
|
||||
autoCollapseSeconds = AutoCollapseDelay.allCases.map(\.rawValue).contains(collapseSeconds)
|
||||
? collapseSeconds
|
||||
: AutoCollapseDelay.five.rawValue
|
||||
let appearanceRawValue = defaults.string(forKey: Self.floatingAppearanceKey) ?? FloatingAppearance.system.rawValue
|
||||
floatingAppearance = FloatingAppearance(rawValue: appearanceRawValue) ?? .system
|
||||
}
|
||||
}
|
||||
79
NativeTokenLens/Sources/TokenLens/BrandMark.swift
Normal file
79
NativeTokenLens/Sources/TokenLens/BrandMark.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BrandMark: View {
|
||||
let phase: Double
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 13, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.09, green: 0.30, blue: 0.82),
|
||||
Color(red: 0.44, green: 0.20, blue: 0.94),
|
||||
Color(red: 0.11, green: 0.71, blue: 0.94)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 13, style: .continuous)
|
||||
.stroke(.white.opacity(0.28), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: .blue.opacity(0.28), radius: 14, y: 8)
|
||||
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(.white.opacity(0.18))
|
||||
.frame(width: 34, height: 16)
|
||||
.blur(radius: 10)
|
||||
.offset(x: -8, y: -12)
|
||||
|
||||
HStack(alignment: .bottom, spacing: 4) {
|
||||
Capsule()
|
||||
.frame(width: 5, height: barHeight(base: 18, amplitude: 5, offset: 0))
|
||||
Capsule()
|
||||
.frame(width: 5, height: barHeight(base: 27, amplitude: 7, offset: 0.8))
|
||||
Capsule()
|
||||
.frame(width: 5, height: barHeight(base: 14, amplitude: 4, offset: 1.6))
|
||||
}
|
||||
.foregroundStyle(.white.opacity(0.94))
|
||||
.offset(x: -6, y: 4)
|
||||
|
||||
Circle()
|
||||
.stroke(.white.opacity(0.92), lineWidth: 3.2)
|
||||
.frame(width: 17, height: 17)
|
||||
.offset(x: 12, y: 6)
|
||||
Capsule()
|
||||
.fill(.white.opacity(0.92))
|
||||
.frame(width: 13, height: 3.2)
|
||||
.rotationEffect(.degrees(-45))
|
||||
.offset(x: 21, y: 15)
|
||||
}
|
||||
.rotation3DEffect(.degrees(sin(phase) * 2.4), axis: (x: 0, y: 1, z: 0))
|
||||
.scaleEffect(1 + sin(phase * 0.7) * 0.018)
|
||||
}
|
||||
|
||||
private func barHeight(base: Double, amplitude: Double, offset: Double) -> CGFloat {
|
||||
CGFloat(base + sin(phase + offset) * amplitude)
|
||||
}
|
||||
}
|
||||
|
||||
struct AnimatedAppearance: ViewModifier {
|
||||
let appeared: Bool
|
||||
let delay: Double
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.opacity(appeared ? 1 : 0)
|
||||
.offset(y: appeared ? 0 : 18)
|
||||
.scaleEffect(appeared ? 1 : 0.985)
|
||||
.animation(.smooth(duration: 0.52).delay(delay), value: appeared)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func animatedAppearance(_ appeared: Bool, delay: Double) -> some View {
|
||||
modifier(AnimatedAppearance(appeared: appeared, delay: delay))
|
||||
}
|
||||
}
|
||||
816
NativeTokenLens/Sources/TokenLens/ChartsAndTables.swift
Normal file
816
NativeTokenLens/Sources/TokenLens/ChartsAndTables.swift
Normal file
@@ -0,0 +1,816 @@
|
||||
import Charts
|
||||
import SwiftUI
|
||||
import TokenLensCore
|
||||
|
||||
struct TrendChart: View {
|
||||
let summary: UsageSummary
|
||||
let mode: TrendMode
|
||||
|
||||
private var points: [DailyPoint] {
|
||||
summary.trendPoints(mode: mode)
|
||||
}
|
||||
|
||||
private var chartPoints: [ProviderTrendPoint] {
|
||||
points.flatMap { point in
|
||||
Provider.allCases.map { provider in
|
||||
ProviderTrendPoint(
|
||||
mode: mode,
|
||||
date: point.date,
|
||||
provider: provider,
|
||||
tokens: point.values[provider, default: 0]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var maxValue: Int {
|
||||
max(1, chartPoints.map(\.tokens).max() ?? 1)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Chart(chartPoints) { point in
|
||||
LineMark(
|
||||
x: .value("日期", point.date),
|
||||
y: .value("Tokens", point.tokens),
|
||||
series: .value("工具", point.provider.rawValue)
|
||||
)
|
||||
.interpolationMethod(.linear)
|
||||
.lineStyle(StrokeStyle(lineWidth: 3.5, lineCap: .round, lineJoin: .round))
|
||||
.foregroundStyle(by: .value("工具", point.provider.rawValue))
|
||||
|
||||
AreaMark(
|
||||
x: .value("日期", point.date),
|
||||
y: .value("Tokens", point.tokens),
|
||||
series: .value("工具", point.provider.rawValue)
|
||||
)
|
||||
.interpolationMethod(.linear)
|
||||
.foregroundStyle(by: .value("工具", point.provider.rawValue))
|
||||
.opacity(point.provider == .codex ? 0.10 : 0.03)
|
||||
}
|
||||
.chartForegroundStyleScale(providerStyleScale)
|
||||
.chartLegend(position: .bottom, alignment: .center, spacing: 18)
|
||||
.chartYScale(domain: 0...(Double(maxValue) * 1.08))
|
||||
.chartPlotStyle { plot in
|
||||
plot.clipped()
|
||||
}
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading, values: .automatic(desiredCount: 5)) { value in
|
||||
AxisGridLine(stroke: StrokeStyle(lineWidth: 1))
|
||||
.foregroundStyle(.white.opacity(0.08))
|
||||
AxisValueLabel {
|
||||
if let tokens = value.as(Double.self) {
|
||||
Text(TokenFormatter.format(Int(tokens)))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .automatic(desiredCount: mode == .daily ? 6 : 5)) { value in
|
||||
AxisGridLine()
|
||||
.foregroundStyle(.clear)
|
||||
AxisValueLabel {
|
||||
if let date = value.as(Date.self) {
|
||||
Text(axisLabel(for: date))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 235)
|
||||
.animation(.spring(response: 0.42, dampingFraction: 0.88), value: mode)
|
||||
}
|
||||
|
||||
private func axisLabel(for date: Date) -> String {
|
||||
switch mode {
|
||||
case .daily:
|
||||
return date.formatted(.dateTime.hour(.twoDigits(amPM: .omitted)))
|
||||
case .weekly:
|
||||
return date.formatted(.dateTime.month(.twoDigits).day(.twoDigits))
|
||||
case .monthly:
|
||||
return date.formatted(.dateTime.month(.twoDigits).day(.twoDigits))
|
||||
case .yearly:
|
||||
return date.formatted(.dateTime.year(.twoDigits).month(.twoDigits))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct UsageShare: View {
|
||||
let summary: UsageSummary
|
||||
|
||||
var total: Int {
|
||||
Provider.allCases.reduce(0) { $0 + (summary.providerTotals[$1]?.totalTokens ?? 0) }
|
||||
}
|
||||
|
||||
private var visibleProviders: [Provider] {
|
||||
let active = Provider.allCases.filter {
|
||||
(summary.providerTotals[$0]?.totalTokens ?? 0) > 0
|
||||
}
|
||||
return active.isEmpty ? Provider.allCases : active
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 28) {
|
||||
VStack(spacing: 10) {
|
||||
PieChartView(summary: summary)
|
||||
.frame(width: 190, height: 190)
|
||||
HStack(spacing: 8) {
|
||||
Text("本月")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(TokenFormatter.format(total))
|
||||
.fontWeight(.bold)
|
||||
.monospacedDigit()
|
||||
}
|
||||
.font(.system(size: 14, weight: .semibold, design: .rounded))
|
||||
}
|
||||
VStack(spacing: 12) {
|
||||
ForEach(visibleProviders, id: \.self) { provider in
|
||||
let providerTotal = summary.providerTotals[provider]?.totalTokens ?? 0
|
||||
let percent = total > 0 ? Int((Double(providerTotal) / Double(total) * 100).rounded()) : 0
|
||||
HStack {
|
||||
Circle().fill(provider.color).frame(width: 10, height: 10)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(provider.rawValue)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
Text("\(TokenFormatter.format(providerTotal)) Tokens")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text("\(percent)%")
|
||||
.font(.system(size: 19, weight: .bold, design: .rounded))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 235)
|
||||
}
|
||||
}
|
||||
|
||||
struct PieChartView: View {
|
||||
let summary: UsageSummary
|
||||
|
||||
var total: Int {
|
||||
Provider.allCases.reduce(0) { $0 + (summary.providerTotals[$1]?.totalTokens ?? 0) }
|
||||
}
|
||||
|
||||
private var slices: [ProviderSlice] {
|
||||
Provider.allCases.map { provider in
|
||||
ProviderSlice(
|
||||
provider: provider,
|
||||
tokens: summary.providerTotals[provider]?.totalTokens ?? 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Chart(slices) { slice in
|
||||
SectorMark(
|
||||
angle: .value("Tokens", max(slice.tokens, 0)),
|
||||
angularInset: 1.8
|
||||
)
|
||||
.cornerRadius(6)
|
||||
.foregroundStyle(by: .value("工具", slice.provider.rawValue))
|
||||
.opacity(total > 0 && slice.tokens > 0 ? 1 : 0)
|
||||
}
|
||||
.chartForegroundStyleScale(providerStyleScale)
|
||||
.chartLegend(.hidden)
|
||||
.chartBackground { _ in
|
||||
if total == 0 {
|
||||
Circle()
|
||||
.fill(.white.opacity(0.10))
|
||||
}
|
||||
}
|
||||
.shadow(color: .purple.opacity(0.24), radius: 18, y: 12)
|
||||
}
|
||||
}
|
||||
|
||||
struct ToolBreakdown: View {
|
||||
let rows: [ToolRow]
|
||||
@Binding var selection: String
|
||||
private let visibleRowLimit = ToolBreakdownLayout.visibleRowLimit
|
||||
|
||||
private var visibleRowCount: Int {
|
||||
min(max(rows.count, 1), visibleRowLimit)
|
||||
}
|
||||
|
||||
private var rowViewportHeight: CGFloat {
|
||||
CGFloat(visibleRowCount) * ToolBreakdownLayout.rowHeight
|
||||
}
|
||||
|
||||
private var providerSignature: String {
|
||||
rows.map { $0.provider.rawValue }.joined(separator: "|")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GlassCard {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("工具明细")
|
||||
.font(.system(size: 21, weight: .bold, design: .rounded))
|
||||
Spacer()
|
||||
NativeMenuPicker(selection: $selection, options: ["全部工具"] + Provider.allCases.map(\.rawValue))
|
||||
.frame(width: 142)
|
||||
}
|
||||
.padding(.bottom, 12)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
ToolBreakdownHeader()
|
||||
ScrollView(showsIndicators: rows.count > visibleRowLimit) {
|
||||
LazyVStack(spacing: 0) {
|
||||
if rows.isEmpty {
|
||||
Text("暂无工具数据")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: ToolBreakdownLayout.rowHeight)
|
||||
} else {
|
||||
ForEach(rows) { row in
|
||||
ToolBreakdownRow(row: row)
|
||||
if row.id != rows.last?.id {
|
||||
Divider()
|
||||
.overlay(.white.opacity(0.08))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.id(providerSignature)
|
||||
.frame(height: rowViewportHeight)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 5)
|
||||
.background(.white.opacity(0.035), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
.overlay(RoundedRectangle(cornerRadius: 18, style: .continuous).stroke(.white.opacity(0.08)))
|
||||
}
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum ToolBreakdownLayout {
|
||||
static let visibleRowLimit = 3
|
||||
static let rowHeight: CGFloat = 54
|
||||
static let headerHeight: CGFloat = 38
|
||||
}
|
||||
|
||||
private enum ToolBreakdownColumns {
|
||||
static let spacing: CGFloat = 16
|
||||
|
||||
static func widths(totalWidth: CGFloat) -> ToolBreakdownColumnWidths {
|
||||
let contentWidth = max(0, totalWidth - spacing * 5)
|
||||
return ToolBreakdownColumnWidths(
|
||||
tool: contentWidth * 0.24,
|
||||
metric: contentWidth * 0.15,
|
||||
trend: contentWidth * 0.16
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToolBreakdownColumnWidths {
|
||||
let tool: CGFloat
|
||||
let metric: CGFloat
|
||||
let trend: CGFloat
|
||||
}
|
||||
|
||||
private struct ToolBreakdownHeader: View {
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
let widths = ToolBreakdownColumns.widths(totalWidth: proxy.size.width)
|
||||
HStack(spacing: ToolBreakdownColumns.spacing) {
|
||||
header("工具名称", width: widths.tool, alignment: .leading)
|
||||
header("输入", width: widths.metric)
|
||||
header("输出", width: widths.metric)
|
||||
header("缓存", width: widths.metric)
|
||||
header("总量", width: widths.metric)
|
||||
header("趋势", width: widths.trend)
|
||||
}
|
||||
.frame(width: proxy.size.width, height: proxy.size.height)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 14)
|
||||
.frame(height: ToolBreakdownLayout.headerHeight)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle()
|
||||
.fill(.white.opacity(0.10))
|
||||
.frame(height: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private func header(_ text: String, width: CGFloat, alignment: Alignment = .center) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: width, alignment: alignment)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToolBreakdownRow: View {
|
||||
let row: ToolRow
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
let widths = ToolBreakdownColumns.widths(totalWidth: proxy.size.width)
|
||||
HStack(spacing: ToolBreakdownColumns.spacing) {
|
||||
providerCell(row.provider)
|
||||
.frame(width: widths.tool, alignment: .leading)
|
||||
metric(TokenFormatter.format(row.inputTokens), width: widths.metric)
|
||||
metric(TokenFormatter.format(row.outputTokens), width: widths.metric)
|
||||
metric(TokenFormatter.format(row.cachedTokens), width: widths.metric)
|
||||
metric(TokenFormatter.format(row.totalTokens), width: widths.metric)
|
||||
Sparkline(provider: row.provider, values: row.trendTokens)
|
||||
.frame(width: 112, height: 30)
|
||||
.frame(width: widths.trend, alignment: .center)
|
||||
}
|
||||
.frame(width: proxy.size.width, height: proxy.size.height)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, 14)
|
||||
.frame(height: ToolBreakdownLayout.rowHeight)
|
||||
}
|
||||
|
||||
private func metric(_ text: String, width: CGFloat) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: 16, weight: .bold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.68)
|
||||
.frame(width: width, alignment: .center)
|
||||
}
|
||||
|
||||
private func providerBadge(_ provider: Provider) -> some View {
|
||||
RoundedRectangle(cornerRadius: 9)
|
||||
.fill(provider.color.gradient)
|
||||
.frame(width: 30, height: 30)
|
||||
.overlay {
|
||||
Text(String(provider.rawValue.prefix(1)))
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
}
|
||||
}
|
||||
|
||||
private func providerCell(_ provider: Provider) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
providerBadge(provider)
|
||||
Text(provider.rawValue)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
struct Sparkline: View {
|
||||
let provider: Provider
|
||||
let values: [Int]
|
||||
|
||||
var body: some View {
|
||||
Chart(points) { point in
|
||||
LineMark(
|
||||
x: .value("序号", point.index),
|
||||
y: .value("趋势", point.value)
|
||||
)
|
||||
.interpolationMethod(.linear)
|
||||
.lineStyle(StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round))
|
||||
.foregroundStyle(provider.color)
|
||||
}
|
||||
.chartXAxis(.hidden)
|
||||
.chartYAxis(.hidden)
|
||||
.chartLegend(.hidden)
|
||||
.chartPlotStyle { plot in
|
||||
plot.background(.clear)
|
||||
}
|
||||
.chartYScale(domain: yDomain)
|
||||
}
|
||||
|
||||
private var points: [SparkPoint] {
|
||||
let source = values.isEmpty ? [0, 0] : values
|
||||
let padded = source.count == 1 ? [0, source[0]] : source
|
||||
return padded.enumerated().map {
|
||||
SparkPoint(index: $0.offset, value: $0.element)
|
||||
}
|
||||
}
|
||||
|
||||
private var yDomain: ClosedRange<Int> {
|
||||
let maxValue = max(1, points.map(\.value).max() ?? 1)
|
||||
return 0...maxValue
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProviderTrendPoint: Identifiable, Hashable {
|
||||
var id: String {
|
||||
"\(mode.rawValue)-\(Int(date.timeIntervalSince1970))-\(provider.rawValue)"
|
||||
}
|
||||
|
||||
let mode: TrendMode
|
||||
let date: Date
|
||||
let provider: Provider
|
||||
let tokens: Int
|
||||
}
|
||||
|
||||
private struct ProviderSlice: Identifiable, Hashable {
|
||||
let id = UUID()
|
||||
let provider: Provider
|
||||
let tokens: Int
|
||||
}
|
||||
|
||||
private struct SparkPoint: Identifiable, Hashable {
|
||||
let id = UUID()
|
||||
let index: Int
|
||||
let value: Int
|
||||
}
|
||||
|
||||
private var providerStyleScale: KeyValuePairs<String, Color> {
|
||||
[
|
||||
Provider.claude.rawValue: Provider.claude.color,
|
||||
Provider.codex.rawValue: Provider.codex.color,
|
||||
Provider.gemini.rawValue: Provider.gemini.color,
|
||||
Provider.hermes.rawValue: Provider.hermes.color,
|
||||
Provider.opencode.rawValue: Provider.opencode.color
|
||||
]
|
||||
}
|
||||
|
||||
struct TokenActivityHeatmap: View {
|
||||
let summary: UsageSummary
|
||||
@State private var mode = "每日"
|
||||
@State private var hoveredDay: HeatmapDay?
|
||||
@State private var tooltipPosition: CGPoint?
|
||||
|
||||
private let cellWidth: CGFloat = 10
|
||||
private let cellHeight: CGFloat = 18
|
||||
private let cellSpacing: CGFloat = 3
|
||||
private let modes = ["每日", "每周", "累计"]
|
||||
|
||||
private var weeks: [[HeatmapDay]] {
|
||||
makeHeatmapWeeks(mode: mode)
|
||||
}
|
||||
|
||||
private var maxValue: Int {
|
||||
max(1, weeks.flatMap { $0 }.map(\.value).max() ?? 1)
|
||||
}
|
||||
|
||||
private var selectedDay: HeatmapDay? {
|
||||
hoveredDay ?? weeks.flatMap { $0 }.last { !$0.isFuture }
|
||||
}
|
||||
|
||||
private var visibleDays: [HeatmapDay] {
|
||||
weeks.flatMap { $0 }.filter { !$0.isFuture }
|
||||
}
|
||||
|
||||
private var activeDays: Int {
|
||||
visibleDays.filter { $0.dailyTotal > 0 }.count
|
||||
}
|
||||
|
||||
private var averageTokens: Int {
|
||||
guard !visibleDays.isEmpty else { return 0 }
|
||||
return visibleDays.reduce(0) { $0 + $1.dailyTotal } / visibleDays.count
|
||||
}
|
||||
|
||||
private var peakTokens: Int {
|
||||
visibleDays.map(\.dailyTotal).max() ?? 0
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GlassCard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Token 活动")
|
||||
.font(.system(size: 21, weight: .bold, design: .rounded))
|
||||
if let selectedDay {
|
||||
Text("\(selectedDay.date.formatted(.dateTime.month().day())) · \(TokenFormatter.format(selectedDay.dailyTotal)) Tokens")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 12) {
|
||||
ForEach(modes, id: \.self) { item in
|
||||
Button(item) {
|
||||
withAnimation(.smooth(duration: 0.22)) {
|
||||
mode = item
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.system(size: 13, weight: mode == item ? .bold : .semibold))
|
||||
.foregroundStyle(mode == item ? .primary : .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
heatmapGrid
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.animation(.smooth(duration: 0.25), value: weeks)
|
||||
|
||||
HStack {
|
||||
Text(monthRangeText)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
HeatmapLegend { value in
|
||||
color(for: HeatmapDay(date: summary.generatedAt, value: value, dailyTotal: value, isFuture: false))
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
HeatmapStat(title: "活跃", value: "\(activeDays) 天")
|
||||
HeatmapStat(title: "日均", value: TokenFormatter.format(averageTokens))
|
||||
HeatmapStat(title: "峰值", value: TokenFormatter.format(peakTokens))
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
|
||||
private var heatmapGrid: some View {
|
||||
let gridWidth = CGFloat(weeks.count) * cellWidth + CGFloat(max(weeks.count - 1, 0)) * cellSpacing
|
||||
let gridHeight = CGFloat(7) * cellHeight + CGFloat(6) * cellSpacing
|
||||
|
||||
return ZStack(alignment: .topLeading) {
|
||||
HStack(alignment: .top, spacing: cellSpacing) {
|
||||
ForEach(Array(weeks.enumerated()), id: \.offset) { weekIndex, week in
|
||||
VStack(spacing: cellSpacing) {
|
||||
ForEach(Array(week.enumerated()), id: \.element.id) { dayIndex, day in
|
||||
RoundedRectangle(cornerRadius: 5, style: .continuous)
|
||||
.fill(color(for: day))
|
||||
.frame(width: cellWidth, height: cellHeight)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 5, style: .continuous)
|
||||
.stroke(.white.opacity(day.dailyTotal > 0 ? 0.18 : 0.03), lineWidth: hoveredDay?.id == day.id ? 1.2 : 0.6)
|
||||
}
|
||||
.opacity(day.isFuture ? 0 : 1)
|
||||
.scaleEffect(hoveredDay?.id == day.id ? 1.16 : 1)
|
||||
.onHover { isInside in
|
||||
if isInside && !day.isFuture {
|
||||
hoveredDay = day
|
||||
tooltipPosition = CGPoint(
|
||||
x: CGFloat(weekIndex) * (cellWidth + cellSpacing) + cellWidth / 2,
|
||||
y: CGFloat(dayIndex) * (cellHeight + cellSpacing)
|
||||
)
|
||||
} else if hoveredDay?.id == day.id {
|
||||
hoveredDay = nil
|
||||
tooltipPosition = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: gridWidth, height: gridHeight, alignment: .topLeading)
|
||||
|
||||
if let hoveredDay, let tooltipPosition {
|
||||
HeatmapTooltip(day: hoveredDay, mode: mode)
|
||||
.fixedSize()
|
||||
.position(
|
||||
x: min(max(tooltipPosition.x, 94), gridWidth - 94),
|
||||
y: max(-20, tooltipPosition.y - 18)
|
||||
)
|
||||
.transition(.scale(scale: 0.96).combined(with: .opacity))
|
||||
.zIndex(10)
|
||||
}
|
||||
}
|
||||
.frame(width: gridWidth, height: gridHeight, alignment: .topLeading)
|
||||
.animation(.smooth(duration: 0.16), value: hoveredDay)
|
||||
}
|
||||
|
||||
private var monthRangeText: String {
|
||||
let flat = weeks.flatMap { $0 }.filter { !$0.isFuture }
|
||||
guard let first = flat.first?.date, let last = flat.last?.date else { return "最近活动" }
|
||||
return "\(first.formatted(.dateTime.month())) - \(last.formatted(.dateTime.month()))"
|
||||
}
|
||||
|
||||
private func color(for day: HeatmapDay) -> Color {
|
||||
guard day.value > 0 else { return .white.opacity(0.06) }
|
||||
let ratio = min(1, sqrt(Double(day.value) / Double(maxValue)))
|
||||
return Color.cyan.opacity(0.18 + ratio * 0.72)
|
||||
}
|
||||
|
||||
private func makeHeatmapWeeks(mode: String) -> [[HeatmapDay]] {
|
||||
var calendar = Calendar.current
|
||||
calendar.firstWeekday = 2
|
||||
let end = calendar.startOfDay(for: summary.generatedAt)
|
||||
let rawStart = calendar.date(byAdding: .day, value: -167, to: end) ?? end
|
||||
let weekday = calendar.component(.weekday, from: rawStart)
|
||||
let daysFromMonday = (weekday + 5) % 7
|
||||
let start = calendar.date(byAdding: .day, value: -daysFromMonday, to: rawStart) ?? rawStart
|
||||
let details = Dictionary(uniqueKeysWithValues: summary.dailyUsageDetails(calendar: calendar).map { ($0.date, $0) })
|
||||
|
||||
var days: [HeatmapDay] = []
|
||||
var cumulative = 0
|
||||
var cursor = start
|
||||
while cursor <= (calendar.date(byAdding: .day, value: 6, to: end) ?? end) {
|
||||
let detail = details[calendar.startOfDay(for: cursor)]
|
||||
let dailyTotal = detail?.totalTokens ?? 0
|
||||
cumulative += cursor <= end ? dailyTotal : 0
|
||||
let value: Int
|
||||
switch mode {
|
||||
case "每周":
|
||||
value = weeklyTotal(for: cursor, details: details, calendar: calendar)
|
||||
case "累计":
|
||||
value = cumulative
|
||||
default:
|
||||
value = dailyTotal
|
||||
}
|
||||
days.append(HeatmapDay(date: cursor, value: value, dailyTotal: dailyTotal, isFuture: cursor > end))
|
||||
guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break }
|
||||
cursor = next
|
||||
}
|
||||
|
||||
return stride(from: 0, to: days.count, by: 7).map { startIndex in
|
||||
Array(days[startIndex..<min(startIndex + 7, days.count)])
|
||||
}
|
||||
}
|
||||
|
||||
private func weeklyTotal(
|
||||
for date: Date,
|
||||
details: [Date: DailyUsageDetail],
|
||||
calendar: Calendar
|
||||
) -> Int {
|
||||
guard let interval = calendar.dateInterval(of: .weekOfYear, for: date) else { return 0 }
|
||||
return details.values.reduce(0) { total, detail in
|
||||
interval.contains(detail.date) ? total + detail.totalTokens : total
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HeatmapDay: Identifiable, Hashable {
|
||||
var id: Date { date }
|
||||
let date: Date
|
||||
let value: Int
|
||||
let dailyTotal: Int
|
||||
let isFuture: Bool
|
||||
}
|
||||
|
||||
private struct HeatmapTooltip: View {
|
||||
let day: HeatmapDay
|
||||
let mode: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text(day.date.formatted(.dateTime.month().day()))
|
||||
.fontWeight(.bold)
|
||||
Text(tooltipText)
|
||||
}
|
||||
.font(.system(size: 15, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.monospacedDigit()
|
||||
.padding(.horizontal, 14)
|
||||
.frame(height: 38)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color(red: 0.13, green: 0.13, blue: 0.14).opacity(0.96))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(.white.opacity(0.16), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.32), radius: 16, y: 10)
|
||||
}
|
||||
}
|
||||
|
||||
private var tooltipText: String {
|
||||
switch mode {
|
||||
case "每周":
|
||||
return "所在周使用了 \(TokenFormatter.format(day.value)) 个 Token"
|
||||
case "累计":
|
||||
return "累计使用了 \(TokenFormatter.format(day.value)) 个 Token"
|
||||
default:
|
||||
return "使用了 \(TokenFormatter.format(day.dailyTotal)) 个 Token"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HeatmapLegend: View {
|
||||
let color: (Int) -> Color
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 5) {
|
||||
Text("少")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
ForEach([0, 1, 2, 3], id: \.self) { index in
|
||||
RoundedRectangle(cornerRadius: 3, style: .continuous)
|
||||
.fill(color(index + 1))
|
||||
.frame(width: 10, height: 10)
|
||||
}
|
||||
Text("多")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HeatmapStat: View {
|
||||
let title: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.frame(maxWidth: .infinity, minHeight: 38, alignment: .leading)
|
||||
.background(.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(RoundedRectangle(cornerRadius: 12).stroke(.white.opacity(0.08)))
|
||||
}
|
||||
}
|
||||
|
||||
struct InsightsPanel: View {
|
||||
let insights: [Insight]
|
||||
|
||||
var body: some View {
|
||||
GlassCard {
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
Text("洞察提醒")
|
||||
.font(.system(size: 21, weight: .bold, design: .rounded))
|
||||
Spacer()
|
||||
Text("查看全部")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
ForEach(insights) { insight in
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(insight.tone.color.opacity(0.2))
|
||||
.frame(width: 36, height: 36)
|
||||
.overlay {
|
||||
Image(systemName: insight.tone.symbol)
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
.foregroundStyle(insight.tone.color)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(insight.title)
|
||||
.font(.system(size: 15, weight: .bold))
|
||||
Text(insight.message)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(12)
|
||||
.background(insight.tone.color.opacity(0.08), in: RoundedRectangle(cornerRadius: 16))
|
||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(insight.tone.color.opacity(0.2)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AmbientBackground: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
LinearGradient(colors: [Color(red: 0.03, green: 0.06, blue: 0.11), Color(red: 0.04, green: 0.09, blue: 0.15)], startPoint: .topLeading, endPoint: .bottomTrailing)
|
||||
Circle()
|
||||
.fill(.blue.opacity(0.16))
|
||||
.frame(width: 520, height: 520)
|
||||
.blur(radius: 90)
|
||||
.offset(x: -380, y: -260)
|
||||
Circle()
|
||||
.fill(.cyan.opacity(0.12))
|
||||
.frame(width: 430, height: 430)
|
||||
.blur(radius: 80)
|
||||
.offset(x: 420, y: -210)
|
||||
Circle()
|
||||
.fill(.purple.opacity(0.14))
|
||||
.frame(width: 520, height: 520)
|
||||
.blur(radius: 110)
|
||||
.offset(x: 280, y: 350)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
extension Provider {
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .claude: .blue
|
||||
case .codex: .purple
|
||||
case .gemini: .cyan
|
||||
case .hermes: .green
|
||||
case .opencode: .orange
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Insight.Tone {
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .warning: .orange
|
||||
case .positive: .green
|
||||
case .accent: .purple
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .warning: "exclamationmark"
|
||||
case .positive: "checkmark"
|
||||
case .accent: "arrow.up.right"
|
||||
}
|
||||
}
|
||||
}
|
||||
866
NativeTokenLens/Sources/TokenLens/DashboardView.swift
Normal file
866
NativeTokenLens/Sources/TokenLens/DashboardView.swift
Normal file
@@ -0,0 +1,866 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import TokenLensCore
|
||||
|
||||
struct DashboardView: View {
|
||||
@StateObject private var store = UsageStore.shared
|
||||
@State private var appeared = false
|
||||
@State private var showingDetails = false
|
||||
@State private var trendMode = "每日"
|
||||
@State private var shareMode = "按 Tokens"
|
||||
@State private var toolFilter = "全部工具"
|
||||
|
||||
var filteredRows: [ToolRow] {
|
||||
if toolFilter == "全部工具" { return store.summary.toolRows }
|
||||
return store.summary.toolRows.filter { $0.provider.rawValue == toolFilter }
|
||||
}
|
||||
|
||||
private var bottomPanelHeight: CGFloat {
|
||||
336
|
||||
}
|
||||
|
||||
private var sidePanelWidth: CGFloat {
|
||||
420
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AmbientBackground()
|
||||
if showingDetails {
|
||||
DailyUsageDetailPage(summary: store.summary) {
|
||||
withAnimation(pageAnimation) {
|
||||
showingDetails = false
|
||||
}
|
||||
}
|
||||
.transition(.asymmetric(
|
||||
insertion: .move(edge: .trailing).combined(with: .opacity),
|
||||
removal: .move(edge: .trailing).combined(with: .opacity)
|
||||
))
|
||||
} else {
|
||||
dashboardContent
|
||||
.transition(.asymmetric(
|
||||
insertion: .move(edge: .leading).combined(with: .opacity),
|
||||
removal: .move(edge: .leading).combined(with: .opacity)
|
||||
))
|
||||
}
|
||||
}
|
||||
.animation(pageAnimation, value: showingDetails)
|
||||
.overlay(alignment: .top) {
|
||||
TitlebarDoubleClickZone()
|
||||
.frame(height: 32)
|
||||
.ignoresSafeArea(edges: .top)
|
||||
}
|
||||
.task {
|
||||
appeared = true
|
||||
store.start()
|
||||
}
|
||||
}
|
||||
|
||||
private var pageAnimation: Animation {
|
||||
.spring(response: 0.42, dampingFraction: 0.90, blendDuration: 0.08)
|
||||
}
|
||||
|
||||
private var dashboardContent: some View {
|
||||
VStack(spacing: 0) {
|
||||
HeaderBar(store: store) {
|
||||
withAnimation(pageAnimation) {
|
||||
showingDetails = true
|
||||
}
|
||||
}
|
||||
.animatedAppearance(appeared, delay: 0.02)
|
||||
VStack(spacing: 0) {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 18) {
|
||||
MetricGrid(cards: store.summary.cards)
|
||||
.animatedAppearance(appeared, delay: 0.08)
|
||||
HStack(spacing: 18) {
|
||||
GlassPanel(title: "Tokens 使用趋势") {
|
||||
TrendChart(summary: store.summary, mode: TrendMode(rawValue: trendMode) ?? .daily)
|
||||
} accessory: {
|
||||
NativeMenuPicker(selection: $trendMode, options: TrendMode.allCases.map(\.rawValue))
|
||||
.frame(width: 126)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
GlassPanel(title: "使用占比") {
|
||||
UsageShare(summary: store.summary)
|
||||
} accessory: {
|
||||
NativeMenuPicker(selection: $shareMode, options: ["按 Tokens", "按占比"])
|
||||
.frame(width: 154)
|
||||
}
|
||||
.frame(width: sidePanelWidth)
|
||||
}
|
||||
.animatedAppearance(appeared, delay: 0.15)
|
||||
HStack(alignment: .top, spacing: 18) {
|
||||
ToolBreakdown(rows: filteredRows, selection: $toolFilter)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: bottomPanelHeight, alignment: .top)
|
||||
TokenActivityHeatmap(summary: store.summary)
|
||||
.frame(width: sidePanelWidth)
|
||||
.frame(height: bottomPanelHeight, alignment: .top)
|
||||
}
|
||||
.frame(height: bottomPanelHeight)
|
||||
.animatedAppearance(appeared, delay: 0.22)
|
||||
}
|
||||
.padding(.horizontal, 26)
|
||||
.padding(.top, 22)
|
||||
.padding(.bottom, 26)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TitlebarDoubleClickZone: NSViewRepresentable {
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
TitlebarHitView()
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {}
|
||||
}
|
||||
|
||||
private final class TitlebarHitView: NSView {
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
guard event.clickCount >= 2 else {
|
||||
window?.performDrag(with: event)
|
||||
return
|
||||
}
|
||||
if let window {
|
||||
WindowActions.toggleMaximize(window)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct HeaderBar: View {
|
||||
@ObservedObject var store: UsageStore
|
||||
let onShowDetails: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
BrandMark(phase: 0)
|
||||
.frame(width: 42, height: 42)
|
||||
Text("TokenLens 用量统计")
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
|
||||
StatusPill(isRefreshing: store.isRefreshing, phase: 0)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: onShowDetails) {
|
||||
Label("查看详情", systemImage: "list.bullet.rectangle.portrait")
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.buttonStyle(GlassButtonStyle())
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("上次更新 \(store.summary.generatedAt.formatted(date: .omitted, time: .shortened))")
|
||||
Text("已刷新 \(store.refreshCount) 次")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.leading, 26)
|
||||
.padding(.trailing, 28)
|
||||
.frame(height: 78)
|
||||
.background(.ultraThinMaterial)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle()
|
||||
.fill(.white.opacity(0.08))
|
||||
.frame(height: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DailyUsageDetailPage: View {
|
||||
let summary: UsageSummary
|
||||
let onBack: () -> Void
|
||||
@State private var selectedYear = ""
|
||||
@State private var selectedMonth = ""
|
||||
@State private var selectedDay = "全部"
|
||||
|
||||
private var details: [DailyUsageDetail] {
|
||||
summary.dailyUsageDetails()
|
||||
}
|
||||
|
||||
private var calendar: Calendar {
|
||||
var calendar = Calendar.current
|
||||
calendar.firstWeekday = 2
|
||||
return calendar
|
||||
}
|
||||
|
||||
private var yearOptions: [String] {
|
||||
let years = Set(details.map { calendar.component(.year, from: $0.date) })
|
||||
let fallbackYear = calendar.component(.year, from: summary.generatedAt)
|
||||
let sortedYears = years.isEmpty ? [fallbackYear] : years.sorted(by: >)
|
||||
return sortedYears.map { "\($0)年" }
|
||||
}
|
||||
|
||||
private var monthLabels: [String] {
|
||||
(1...12).map { "\($0)月" }
|
||||
}
|
||||
|
||||
private var dayLabels: [String] {
|
||||
guard let monthDate = selectedMonthDate,
|
||||
let range = calendar.range(of: .day, in: .month, for: monthDate)
|
||||
else {
|
||||
return ["全部"]
|
||||
}
|
||||
return ["全部"] + range.map { "\($0)日" }
|
||||
}
|
||||
|
||||
private var selectedYearValue: Int? {
|
||||
Int(selectedYear.replacingOccurrences(of: "年", with: ""))
|
||||
}
|
||||
|
||||
private var selectedMonthValue: Int? {
|
||||
Int(selectedMonth.replacingOccurrences(of: "月", with: ""))
|
||||
}
|
||||
|
||||
private var selectedDayValue: Int? {
|
||||
guard selectedDay != "全部" else { return nil }
|
||||
return Int(selectedDay.replacingOccurrences(of: "日", with: ""))
|
||||
}
|
||||
|
||||
private var selectedMonthDate: Date? {
|
||||
guard let selectedYearValue, let selectedMonthValue else { return nil }
|
||||
var components = DateComponents()
|
||||
components.year = selectedYearValue
|
||||
components.month = selectedMonthValue
|
||||
components.day = 1
|
||||
return calendar.date(from: components)
|
||||
}
|
||||
|
||||
private var filteredDetails: [DailyUsageDetail] {
|
||||
guard let selectedYearValue, let selectedMonthValue else { return [] }
|
||||
return details.filter {
|
||||
let components = calendar.dateComponents([.year, .month, .day], from: $0.date)
|
||||
guard components.year == selectedYearValue, components.month == selectedMonthValue else {
|
||||
return false
|
||||
}
|
||||
if let selectedDayValue {
|
||||
return components.day == selectedDayValue
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedRangeTotal: Int {
|
||||
filteredDetails.reduce(0) { $0 + $1.totalTokens }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 14) {
|
||||
Button(action: onBack) {
|
||||
Label("返回", systemImage: "chevron.left")
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.buttonStyle(GlassButtonStyle())
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("每日用量详情")
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
Text("按本地日期汇总输入、输出、缓存和每个工具的 Tokens")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 3) {
|
||||
Text("筛选日期")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 12) {
|
||||
DateFilterControl(
|
||||
year: $selectedYear,
|
||||
month: $selectedMonth,
|
||||
day: $selectedDay,
|
||||
yearOptions: yearOptions,
|
||||
monthOptions: monthLabels,
|
||||
dayOptions: dayLabels
|
||||
)
|
||||
VStack(alignment: .trailing, spacing: 1) {
|
||||
Text("合计")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(TokenFormatter.format(selectedRangeTotal))
|
||||
.font(.system(size: 18, weight: .bold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 26)
|
||||
.frame(height: 78)
|
||||
.background(.ultraThinMaterial)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle()
|
||||
.fill(.white.opacity(0.08))
|
||||
.frame(height: 1)
|
||||
}
|
||||
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 14) {
|
||||
DailyUsageHeader()
|
||||
ForEach(filteredDetails) { detail in
|
||||
DailyUsageRow(detail: detail)
|
||||
}
|
||||
if filteredDetails.isEmpty {
|
||||
EmptyDailyUsageState()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 26)
|
||||
.padding(.top, 22)
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
}
|
||||
.onAppear(perform: ensureDateSelection)
|
||||
.onChange(of: summary.cards.totalTokens) { _, _ in
|
||||
ensureDateSelection()
|
||||
}
|
||||
.onChange(of: selectedYear) { _, _ in
|
||||
ensureDaySelection()
|
||||
}
|
||||
.onChange(of: selectedMonth) { _, newValue in
|
||||
guard !newValue.isEmpty else { return }
|
||||
ensureDaySelection()
|
||||
}
|
||||
.onChange(of: selectedDay) { _, newValue in
|
||||
if !dayLabels.contains(newValue) {
|
||||
selectedDay = "全部"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureDateSelection() {
|
||||
let latestDate = details.first?.date ?? summary.generatedAt
|
||||
let components = calendar.dateComponents([.year, .month], from: latestDate)
|
||||
let fallbackYear = "\(components.year ?? calendar.component(.year, from: summary.generatedAt))年"
|
||||
let fallbackMonth = "\(components.month ?? calendar.component(.month, from: summary.generatedAt))月"
|
||||
|
||||
if selectedYear.isEmpty || !yearOptions.contains(selectedYear) {
|
||||
selectedYear = yearOptions.first ?? fallbackYear
|
||||
}
|
||||
if selectedMonth.isEmpty || !monthLabels.contains(selectedMonth) {
|
||||
selectedMonth = fallbackMonth
|
||||
}
|
||||
ensureDaySelection()
|
||||
}
|
||||
|
||||
private func ensureDaySelection() {
|
||||
if selectedDay.isEmpty || !dayLabels.contains(selectedDay) {
|
||||
selectedDay = "全部"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DailyUsageHeader: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 18) {
|
||||
Text("日期").frame(width: DailyUsageColumnLayout.date, alignment: .center)
|
||||
Text("总量").frame(width: DailyUsageColumnLayout.total, alignment: .center)
|
||||
Text("输入").frame(width: DailyUsageColumnLayout.metric, alignment: .center)
|
||||
Text("输出").frame(width: DailyUsageColumnLayout.metric, alignment: .center)
|
||||
Text("缓存").frame(width: DailyUsageColumnLayout.metric, alignment: .center)
|
||||
Text("工具分布").frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 22)
|
||||
.frame(height: 44)
|
||||
.background(.white.opacity(0.055), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(.white.opacity(0.08)))
|
||||
}
|
||||
}
|
||||
|
||||
private struct DateFilterControl: View {
|
||||
@Binding var year: String
|
||||
@Binding var month: String
|
||||
@Binding var day: String
|
||||
let yearOptions: [String]
|
||||
let monthOptions: [String]
|
||||
let dayOptions: [String]
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
PrettyFilterMenu(title: "年", selection: $year, options: yearOptions)
|
||||
.frame(width: 104)
|
||||
Divider()
|
||||
.frame(height: 22)
|
||||
.overlay(.white.opacity(0.10))
|
||||
PrettyFilterMenu(title: "月", selection: $month, options: monthOptions)
|
||||
.frame(width: 86)
|
||||
Divider()
|
||||
.frame(height: 22)
|
||||
.overlay(.white.opacity(0.10))
|
||||
PrettyFilterMenu(title: "日", selection: $day, options: dayOptions)
|
||||
.frame(width: 86)
|
||||
}
|
||||
.frame(height: 42)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(.thinMaterial)
|
||||
.overlay {
|
||||
LinearGradient(
|
||||
colors: [.white.opacity(0.20), .white.opacity(0.04)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
}
|
||||
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(.white.opacity(0.18)))
|
||||
.shadow(color: .black.opacity(0.12), radius: 12, y: 8)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PrettyFilterMenu: View {
|
||||
let title: String
|
||||
@Binding var selection: String
|
||||
let options: [String]
|
||||
@State private var showingOptions = false
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
showingOptions.toggle()
|
||||
} label: {
|
||||
HStack(spacing: 7) {
|
||||
Text(title)
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(selection)
|
||||
.font(.system(size: 14, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.82)
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.system(size: 9, weight: .black))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.popover(isPresented: $showingOptions, arrowEdge: .bottom) {
|
||||
DropdownOptionList(selection: selection, options: options) { option in
|
||||
selection = option
|
||||
showingOptions = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct EmptyDailyUsageState: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "calendar.badge.exclamationmark")
|
||||
.font(.system(size: 28, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("这个日期范围还没有统计数据")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
Text("TokenLens 会继续扫描本地日志,有新记录后这里会自动刷新。")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 42)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DailyUsageRow: View {
|
||||
let detail: DailyUsageDetail
|
||||
@State private var hovering = false
|
||||
|
||||
var body: some View {
|
||||
GlassCard {
|
||||
HStack(spacing: 18) {
|
||||
VStack(alignment: .center, spacing: 4) {
|
||||
Text(detail.date.formatted(.dateTime.month(.twoDigits).day(.twoDigits)))
|
||||
.font(.system(size: 19, weight: .bold, design: .rounded))
|
||||
Text(detail.date.formatted(.dateTime.weekday(.wide)))
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(width: DailyUsageColumnLayout.date, alignment: .center)
|
||||
|
||||
metric(TokenFormatter.format(detail.totalTokens), tint: .primary, width: DailyUsageColumnLayout.total)
|
||||
metric(TokenFormatter.format(detail.inputTokens), tint: .blue, width: DailyUsageColumnLayout.metric)
|
||||
metric(TokenFormatter.format(detail.outputTokens), tint: .green, width: DailyUsageColumnLayout.metric)
|
||||
metric(TokenFormatter.format(detail.cachedTokens), tint: .orange, width: DailyUsageColumnLayout.metric)
|
||||
|
||||
ProviderUsageDistribution(detail: detail)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.scaleEffect(hovering ? 1.006 : 1)
|
||||
.animation(.smooth(duration: 0.18), value: hovering)
|
||||
.onHover { hovering = $0 }
|
||||
}
|
||||
|
||||
private func metric(_ text: String, tint: Color, width: CGFloat) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: 18, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(tint)
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.76)
|
||||
.frame(width: width, alignment: .center)
|
||||
}
|
||||
}
|
||||
|
||||
private enum DailyUsageColumnLayout {
|
||||
static let date: CGFloat = 112
|
||||
static let total: CGFloat = 98
|
||||
static let metric: CGFloat = 88
|
||||
}
|
||||
|
||||
private struct ProviderUsageDistribution: View {
|
||||
let detail: DailyUsageDetail
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 9) {
|
||||
GeometryReader { proxy in
|
||||
HStack(spacing: 3) {
|
||||
ForEach(activeProviders, id: \.self) { provider in
|
||||
RoundedRectangle(cornerRadius: 5, style: .continuous)
|
||||
.fill(provider.color.gradient)
|
||||
.frame(width: segmentWidths(totalWidth: proxy.size.width)[provider, default: 0])
|
||||
}
|
||||
}
|
||||
.frame(width: proxy.size.width, alignment: .leading)
|
||||
.clipped()
|
||||
}
|
||||
.frame(height: 11)
|
||||
.background(.white.opacity(0.055), in: RoundedRectangle(cornerRadius: 5, style: .continuous))
|
||||
.overlay(RoundedRectangle(cornerRadius: 5, style: .continuous).stroke(.white.opacity(0.08)))
|
||||
|
||||
HStack(spacing: 10) {
|
||||
ForEach(Provider.allCases, id: \.self) { provider in
|
||||
providerCell(provider)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(.white.opacity(0.045), in: RoundedRectangle(cornerRadius: 15, style: .continuous))
|
||||
.overlay(RoundedRectangle(cornerRadius: 15, style: .continuous).stroke(.white.opacity(0.09)))
|
||||
}
|
||||
|
||||
private func providerCell(_ provider: Provider) -> some View {
|
||||
let value = detail.providerTotals[provider, default: 0]
|
||||
let percent = detail.totalTokens > 0 ? Int((Double(value) / Double(detail.totalTokens) * 100).rounded()) : 0
|
||||
return HStack(spacing: 7) {
|
||||
Circle()
|
||||
.fill(provider.color)
|
||||
.frame(width: 7, height: 7)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(provider.rawValue)
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(TokenFormatter.format(value)) · \(percent)%")
|
||||
.font(.system(size: 11, weight: .bold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.72)
|
||||
}
|
||||
.frame(minWidth: 0, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
private var activeProviders: [Provider] {
|
||||
let providers = Provider.allCases.filter { detail.providerTotals[$0, default: 0] > 0 }
|
||||
return providers.isEmpty ? Provider.allCases : providers
|
||||
}
|
||||
|
||||
private func segmentWidths(totalWidth: CGFloat) -> [Provider: CGFloat] {
|
||||
let providers = activeProviders
|
||||
guard totalWidth > 0, !providers.isEmpty else { return [:] }
|
||||
|
||||
let spacing: CGFloat = 3
|
||||
let totalSpacing = spacing * CGFloat(max(providers.count - 1, 0))
|
||||
let availableWidth = max(0, totalWidth - totalSpacing)
|
||||
|
||||
guard detail.totalTokens > 0 else {
|
||||
let width = availableWidth / CGFloat(providers.count)
|
||||
return Dictionary(uniqueKeysWithValues: providers.map { ($0, width) })
|
||||
}
|
||||
|
||||
let minWidth: CGFloat = 8
|
||||
let minTotal = minWidth * CGFloat(providers.count)
|
||||
if minTotal >= availableWidth {
|
||||
let width = availableWidth / CGFloat(providers.count)
|
||||
return Dictionary(uniqueKeysWithValues: providers.map { ($0, width) })
|
||||
}
|
||||
|
||||
let remainingWidth = availableWidth - minTotal
|
||||
return Dictionary(uniqueKeysWithValues: providers.map { provider in
|
||||
let value = CGFloat(detail.providerTotals[provider, default: 0])
|
||||
let proportional = remainingWidth * value / CGFloat(detail.totalTokens)
|
||||
return (provider, minWidth + proportional)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusPill: View {
|
||||
let isRefreshing: Bool
|
||||
let phase: Double
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(isRefreshing ? Color.orange : Color.green)
|
||||
.frame(width: 8, height: 8)
|
||||
.shadow(color: isRefreshing ? .orange.opacity(0.45) : .green.opacity(0.35), radius: 6)
|
||||
Text(isRefreshing ? "正在刷新" : "每 15 秒自动刷新")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: 30)
|
||||
.background(.thinMaterial, in: Capsule())
|
||||
.overlay(Capsule().stroke(.white.opacity(0.12)))
|
||||
}
|
||||
}
|
||||
|
||||
struct MetricGrid: View {
|
||||
let cards: SummaryCards
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 18) {
|
||||
MetricCard(title: "今日 Tokens", value: TokenFormatter.format(cards.todayTokens), tint: .blue, icon: "square.stack.3d.down.right.fill", footnote: "实时 本地今日已统计", delay: 0)
|
||||
MetricCard(title: "本月 Tokens", value: TokenFormatter.format(cards.monthTokens), tint: .green, icon: "sparkles", footnote: "月度 按本地时区汇总", delay: 0.06)
|
||||
MetricCard(title: "历史总量", value: TokenFormatter.format(cards.totalTokens), tint: .purple, icon: "checkmark", footnote: "全部 已扫描 \(cards.sessionCount) 个会话", delay: 0.12)
|
||||
MetricCard(title: "缓存命中", value: "\(cards.cacheHitRate)%", tint: .orange, icon: "arrow.up.right", footnote: "缓存 来自缓存上下文", delay: 0.18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MetricCard: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let tint: Color
|
||||
let icon: String
|
||||
let footnote: String
|
||||
let delay: Double
|
||||
@State private var hovering = false
|
||||
|
||||
var body: some View {
|
||||
GlassCard(padding: 18, cornerRadius: 22) {
|
||||
HStack(spacing: 16) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(tint.gradient)
|
||||
Circle()
|
||||
.stroke(.white.opacity(0.30), lineWidth: 1)
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 23, weight: .bold))
|
||||
.foregroundStyle(.white.opacity(0.92))
|
||||
}
|
||||
.frame(width: 54, height: 54)
|
||||
.shadow(color: tint.opacity(0.24), radius: 12, y: 6)
|
||||
.rotation3DEffect(.degrees(hovering ? 6 : 0), axis: (x: 0, y: 1, z: 0))
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 5) {
|
||||
Text(title)
|
||||
Image(systemName: "info.circle")
|
||||
}
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.system(size: 32, weight: .bold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.72)
|
||||
.contentTransition(.numericText())
|
||||
Text(footnote)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.80)
|
||||
.foregroundStyle(tint, .secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
.scaleEffect(hovering ? 1.01 : 1)
|
||||
.offset(y: hovering ? -2 : 0)
|
||||
.animation(.smooth(duration: 0.18), value: hovering)
|
||||
.frame(height: 124)
|
||||
.onHover { hovering = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
struct GlassPanel<Content: View, Accessory: View>: View {
|
||||
let title: String
|
||||
@ViewBuilder let content: Content
|
||||
@ViewBuilder let accessory: Accessory
|
||||
|
||||
var body: some View {
|
||||
GlassCard {
|
||||
VStack(spacing: 14) {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.system(size: 21, weight: .bold, design: .rounded))
|
||||
Spacer()
|
||||
accessory
|
||||
}
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GlassCard<Content: View>: View {
|
||||
@ViewBuilder let content: Content
|
||||
var padding: CGFloat = 22
|
||||
var cornerRadius: CGFloat = 24
|
||||
@State private var hovering = false
|
||||
|
||||
init(
|
||||
padding: CGFloat = 22,
|
||||
cornerRadius: CGFloat = 24,
|
||||
@ViewBuilder content: () -> Content
|
||||
) {
|
||||
self.padding = padding
|
||||
self.cornerRadius = cornerRadius
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.padding(padding)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay {
|
||||
LinearGradient(
|
||||
colors: [.white.opacity(0.18), .white.opacity(0.04), .clear],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
.stroke(.white.opacity(hovering ? 0.28 : 0.16), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.22), radius: hovering ? 32 : 28, y: hovering ? 20 : 18)
|
||||
}
|
||||
.animation(.smooth(duration: 0.18), value: hovering)
|
||||
.onHover { hovering = $0 }
|
||||
}
|
||||
}
|
||||
|
||||
struct NativeMenuPicker: View {
|
||||
@Binding var selection: String
|
||||
let options: [String]
|
||||
@State private var showingOptions = false
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
showingOptions.toggle()
|
||||
} label: {
|
||||
HStack(spacing: 9) {
|
||||
Text(selection)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.84)
|
||||
Spacer()
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(.white.opacity(0.10))
|
||||
.frame(width: 20, height: 20)
|
||||
Image(systemName: showingOptions ? "chevron.up" : "chevron.down")
|
||||
.font(.system(size: 9, weight: .black))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 14, weight: .bold, design: .rounded))
|
||||
.padding(.horizontal, 14)
|
||||
.frame(height: 42)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(.thinMaterial)
|
||||
.overlay {
|
||||
LinearGradient(
|
||||
colors: [.white.opacity(0.22), .white.opacity(0.05)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
}
|
||||
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(.white.opacity(0.20)))
|
||||
.shadow(color: .black.opacity(0.12), radius: 10, y: 7)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.popover(isPresented: $showingOptions, arrowEdge: .bottom) {
|
||||
DropdownOptionList(selection: selection, options: options) { option in
|
||||
selection = option
|
||||
showingOptions = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DropdownOptionList: View {
|
||||
let selection: String
|
||||
let options: [String]
|
||||
let onSelect: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
if options.isEmpty {
|
||||
Text("暂无选项")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, minHeight: 34)
|
||||
} else {
|
||||
ForEach(options, id: \.self) { option in
|
||||
Button {
|
||||
onSelect(option)
|
||||
} label: {
|
||||
HStack(spacing: 9) {
|
||||
Text(option)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if option == selection {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 13, weight: option == selection ? .bold : .semibold, design: .rounded))
|
||||
.padding(.horizontal, 10)
|
||||
.frame(height: 34)
|
||||
.background(
|
||||
option == selection ? Color.blue.opacity(0.16) : Color.white.opacity(0.001),
|
||||
in: RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(6)
|
||||
.frame(minWidth: 136)
|
||||
.background(.ultraThinMaterial)
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
|
||||
struct GlassButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 42)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 13, style: .continuous))
|
||||
.overlay(RoundedRectangle(cornerRadius: 13).stroke(.white.opacity(0.16)))
|
||||
.scaleEffect(configuration.isPressed ? 0.97 : 1)
|
||||
.animation(.smooth(duration: 0.16), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
248
NativeTokenLens/Sources/TokenLens/FloatingTokenWindow.swift
Normal file
248
NativeTokenLens/Sources/TokenLens/FloatingTokenWindow.swift
Normal file
@@ -0,0 +1,248 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import TokenLensCore
|
||||
|
||||
struct FloatingTokenWindow: View {
|
||||
@ObservedObject var store: UsageStore
|
||||
let onSizeChange: (CGSize) -> Void
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@StateObject private var settings = AppSettings.shared
|
||||
@State private var expanded = false
|
||||
@State private var collapseTask: Task<Void, Never>?
|
||||
|
||||
private var todayText: String {
|
||||
TokenFormatter.format(store.summary.cards.todayTokens)
|
||||
}
|
||||
|
||||
private var targetWidth: CGFloat {
|
||||
let digitWidth = CGFloat(max(todayText.count - 3, 0)) * 11
|
||||
return expanded ? max(338, 282 + digitWidth) : min(220, max(142, 120 + digitWidth))
|
||||
}
|
||||
|
||||
private var visibleProviders: [Provider] {
|
||||
Provider.allCases.filter {
|
||||
(store.summary.todayProviderTotals[$0]?.totalTokens ?? 0) > 0
|
||||
}
|
||||
}
|
||||
|
||||
private var providerRowCount: Int {
|
||||
max(visibleProviders.count, 1)
|
||||
}
|
||||
|
||||
private var activeProviderSignature: String {
|
||||
visibleProviders.map(\.rawValue).joined(separator: "|")
|
||||
}
|
||||
|
||||
private var targetHeight: CGFloat {
|
||||
expanded ? 84 + CGFloat(providerRowCount) * 62 + CGFloat(max(providerRowCount - 1, 0)) * 8 : 36
|
||||
}
|
||||
private var cornerRadius: CGFloat { expanded ? 18 : targetHeight / 2 }
|
||||
private var expansionAnimation: Animation {
|
||||
.spring(response: 0.38, dampingFraction: 0.92, blendDuration: 0.10)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
.fill(panelFill)
|
||||
.overlay {
|
||||
LinearGradient(
|
||||
colors: [.white.opacity(colorScheme == .dark ? 0.18 : 0.46), .white.opacity(0.04)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
.stroke(.white.opacity(colorScheme == .dark ? 0.18 : 0.54), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: .black.opacity(colorScheme == .dark ? 0.32 : 0.16), radius: expanded ? 16 : 10, y: expanded ? 10 : 5)
|
||||
|
||||
content
|
||||
.padding(expanded ? EdgeInsets(top: 14, leading: 12, bottom: 14, trailing: 12) : EdgeInsets(top: 7, leading: 7, bottom: 7, trailing: 7))
|
||||
}
|
||||
.frame(width: targetWidth, height: targetHeight, alignment: .topLeading)
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
.animation(expansionAnimation, value: expanded)
|
||||
.animation(.smooth(duration: 0.22), value: store.summary.cards.totalTokens)
|
||||
.contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
.onTapGesture {
|
||||
toggleExpanded()
|
||||
}
|
||||
.onChange(of: settings.autoCollapseSeconds) { _, _ in
|
||||
if expanded {
|
||||
scheduleAutoCollapse()
|
||||
}
|
||||
}
|
||||
.onAppear(perform: reportSize)
|
||||
.onChange(of: expanded) { _, _ in reportSize() }
|
||||
.onChange(of: todayText) { _, _ in reportSize() }
|
||||
.onChange(of: activeProviderSignature) { _, _ in reportSize() }
|
||||
.preferredColorScheme(settings.floatingAppearance.colorScheme)
|
||||
}
|
||||
|
||||
private var panelFill: Color {
|
||||
if colorScheme == .dark {
|
||||
return Color(red: 0.08, green: 0.09, blue: 0.11).opacity(0.86)
|
||||
}
|
||||
return Color.white.opacity(0.88)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
VStack(alignment: .leading, spacing: expanded ? 12 : 0) {
|
||||
if expanded {
|
||||
HStack(spacing: 8) {
|
||||
FloatingTrendIcon()
|
||||
.frame(width: 32, height: 32)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("今日 Tokens")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(todayText)
|
||||
.font(.system(size: 22, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.primary)
|
||||
.monospacedDigit()
|
||||
.contentTransition(.numericText())
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
VStack(alignment: .trailing, spacing: 1) {
|
||||
Text("实时")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
Text("15 秒刷新")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(spacing: 8) {
|
||||
if visibleProviders.isEmpty {
|
||||
Text("今日暂无工具流量")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 54)
|
||||
.background(.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 11, style: .continuous))
|
||||
} else {
|
||||
ForEach(visibleProviders, id: \.self) { provider in
|
||||
FloatingProviderRow(
|
||||
provider: provider,
|
||||
bucket: store.summary.todayProviderTotals[provider] ?? TokenBucket(provider: provider)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
HStack(spacing: 6) {
|
||||
FloatingTrendIcon()
|
||||
.frame(width: 22, height: 22)
|
||||
Text("今日")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(todayText)
|
||||
.font(.system(size: 15, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.primary)
|
||||
.monospacedDigit()
|
||||
.contentTransition(.numericText())
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleExpanded() {
|
||||
collapseTask?.cancel()
|
||||
withAnimation(expansionAnimation) {
|
||||
expanded.toggle()
|
||||
}
|
||||
if expanded {
|
||||
scheduleAutoCollapse()
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleAutoCollapse() {
|
||||
collapseTask?.cancel()
|
||||
let delay = UInt64(max(1, settings.autoCollapseSeconds)) * 1_000_000_000
|
||||
collapseTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: delay)
|
||||
guard !Task.isCancelled else { return }
|
||||
withAnimation(expansionAnimation) {
|
||||
expanded = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reportSize() {
|
||||
let size = CGSize(width: targetWidth, height: targetHeight)
|
||||
DispatchQueue.main.async {
|
||||
onSizeChange(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct FloatingTrendIcon: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 9, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [.blue.opacity(0.88), .purple.opacity(0.82)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 9, style: .continuous)
|
||||
.stroke(.white.opacity(0.26), lineWidth: 1)
|
||||
}
|
||||
Image(systemName: "chart.line.uptrend.xyaxis")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.shadow(color: .blue.opacity(0.24), radius: 8, y: 4)
|
||||
}
|
||||
}
|
||||
|
||||
private struct FloatingProviderRow: View {
|
||||
let provider: Provider
|
||||
let bucket: TokenBucket
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 5) {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(provider.color)
|
||||
.frame(width: 7, height: 7)
|
||||
Text(provider.rawValue)
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
Spacer()
|
||||
Text(TokenFormatter.format(bucket.totalTokens))
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
detail("输入", bucket.inputTokens)
|
||||
detail("输出", bucket.outputTokens)
|
||||
detail("缓存", bucket.cachedTokens)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 7)
|
||||
.background(provider.color.opacity(0.08), in: RoundedRectangle(cornerRadius: 11, style: .continuous))
|
||||
.overlay(RoundedRectangle(cornerRadius: 11, style: .continuous).stroke(provider.color.opacity(0.16)))
|
||||
}
|
||||
|
||||
private func detail(_ label: String, _ value: Int) -> some View {
|
||||
HStack(spacing: 3) {
|
||||
Text(label)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(TokenFormatter.format(value))
|
||||
.fontWeight(.semibold)
|
||||
.monospacedDigit()
|
||||
}
|
||||
.font(.system(size: 10))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
114
NativeTokenLens/Sources/TokenLens/SettingsView.swift
Normal file
114
NativeTokenLens/Sources/TokenLens/SettingsView.swift
Normal file
@@ -0,0 +1,114 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@StateObject private var settings = AppSettings.shared
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("TokenLens 设置")
|
||||
.font(.system(size: 22, weight: .bold, design: .rounded))
|
||||
Text("控制桌面辅助入口和菜单栏显示。")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
VStack(spacing: 12) {
|
||||
SettingsToggleRow(
|
||||
title: "显示悬浮窗",
|
||||
subtitle: "在桌面上显示总 Tokens 消耗,鼠标放上去展开详情。",
|
||||
systemImage: "rectangle.on.rectangle",
|
||||
isOn: $settings.showFloatingWindow
|
||||
)
|
||||
SettingsToggleRow(
|
||||
title: "显示状态栏提示窗",
|
||||
subtitle: "在 macOS 菜单栏显示当前总 Tokens 数量。",
|
||||
systemImage: "menubar.rectangle",
|
||||
isOn: $settings.showStatusBarIndicator
|
||||
)
|
||||
SettingsPickerRow(
|
||||
title: "自动回收",
|
||||
subtitle: "悬浮窗和状态栏详情展开后的自动收起时间。",
|
||||
systemImage: "timer",
|
||||
selection: $settings.autoCollapseSeconds,
|
||||
options: AutoCollapseDelay.allCases.map { ($0.rawValue, $0.label) }
|
||||
)
|
||||
SettingsPickerRow(
|
||||
title: "悬浮窗皮肤",
|
||||
subtitle: "控制悬浮窗亮色、暗色或跟随系统主题。",
|
||||
systemImage: "circle.lefthalf.filled",
|
||||
selection: Binding(
|
||||
get: { settings.floatingAppearance.rawValue },
|
||||
set: { settings.floatingAppearance = FloatingAppearance(rawValue: $0) ?? .system }
|
||||
),
|
||||
options: FloatingAppearance.allCases.map { ($0.rawValue, $0.rawValue) }
|
||||
)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(26)
|
||||
.frame(width: 520, height: 430)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsToggleRow: View {
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let systemImage: String
|
||||
@Binding var isOn: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.frame(width: 34, height: 34)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
Text(subtitle)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Toggle("", isOn: $isOn)
|
||||
.toggleStyle(.switch)
|
||||
.labelsHidden()
|
||||
}
|
||||
.padding(12)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsPickerRow<Value: Hashable>: View {
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let systemImage: String
|
||||
@Binding var selection: Value
|
||||
let options: [(Value, String)]
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.frame(width: 34, height: 34)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(title)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
Text(subtitle)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Picker("", selection: $selection) {
|
||||
ForEach(options, id: \.0) { value, label in
|
||||
Text(label).tag(value)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: 190)
|
||||
}
|
||||
.padding(12)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
}
|
||||
446
NativeTokenLens/Sources/TokenLens/TokenLensApp.swift
Normal file
446
NativeTokenLens/Sources/TokenLens/TokenLensApp.swift
Normal file
@@ -0,0 +1,446 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import Combine
|
||||
import TokenLensCore
|
||||
|
||||
@main
|
||||
struct TokenLensApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
Settings {
|
||||
SettingsView()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
.commands {
|
||||
CommandGroup(replacing: .newItem) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
|
||||
private var dashboardWindow: NSWindow?
|
||||
private var floatingPanel: NSPanel?
|
||||
private var statusItem: NSStatusItem?
|
||||
private var statusPopover: NSPopover?
|
||||
private var statusPopoverCloseTask: Task<Void, Never>?
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
private var isTerminating = false
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
UsageStore.shared.start()
|
||||
bindAppState()
|
||||
updateFloatingPanelVisibility()
|
||||
updateStatusItemVisibility()
|
||||
}
|
||||
|
||||
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
||||
Task { @MainActor in
|
||||
showDashboardWindow()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
isTerminating = true
|
||||
return .terminateNow
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func showDashboardWindow() {
|
||||
closeStatusPopover()
|
||||
|
||||
if let dashboardWindow {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
if dashboardWindow.isMiniaturized {
|
||||
dashboardWindow.deminiaturize(nil)
|
||||
}
|
||||
dashboardWindow.makeKeyAndOrderFront(nil)
|
||||
dashboardWindow.orderFrontRegardless()
|
||||
return
|
||||
}
|
||||
|
||||
let hostingView = NSHostingView(
|
||||
rootView: DashboardView()
|
||||
.frame(minWidth: 1180, minHeight: 760)
|
||||
.preferredColorScheme(.dark)
|
||||
)
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 1280, height: 860),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "TokenLens"
|
||||
window.identifier = NSUserInterfaceItemIdentifier("main-dashboard-window")
|
||||
window.titleVisibility = .hidden
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.isMovableByWindowBackground = false
|
||||
window.backgroundColor = .clear
|
||||
window.isOpaque = false
|
||||
window.minSize = NSSize(width: 1180, height: 760)
|
||||
window.contentView = hostingView
|
||||
window.delegate = self
|
||||
window.center()
|
||||
|
||||
dashboardWindow = window
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
window.orderFrontRegardless()
|
||||
}
|
||||
|
||||
func windowWillClose(_ notification: Notification) {
|
||||
if notification.object as? NSWindow === dashboardWindow {
|
||||
dashboardWindow = nil
|
||||
}
|
||||
}
|
||||
|
||||
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
||||
guard sender === dashboardWindow, !isTerminating else {
|
||||
return true
|
||||
}
|
||||
sender.miniaturize(nil)
|
||||
return false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func bindAppState() {
|
||||
AppSettings.shared.$showFloatingWindow
|
||||
.sink { [weak self] isVisible in self?.updateFloatingPanelVisibility(isVisible: isVisible) }
|
||||
.store(in: &cancellables)
|
||||
AppSettings.shared.$showStatusBarIndicator
|
||||
.sink { [weak self] isVisible in self?.updateStatusItemVisibility(isVisible: isVisible) }
|
||||
.store(in: &cancellables)
|
||||
UsageStore.shared.$summary
|
||||
.sink { [weak self] _ in self?.updateStatusTitle() }
|
||||
.store(in: &cancellables)
|
||||
AppSettings.shared.$floatingAppearance
|
||||
.sink { [weak self] _ in
|
||||
self?.applyFloatingPanelAppearance()
|
||||
self?.applyStatusPopoverAppearance()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func updateFloatingPanelVisibility(isVisible: Bool = AppSettings.shared.showFloatingWindow) {
|
||||
if isVisible {
|
||||
if floatingPanel == nil {
|
||||
installFloatingPanel()
|
||||
}
|
||||
floatingPanel?.orderFrontRegardless()
|
||||
} else {
|
||||
floatingPanel?.orderOut(nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func installFloatingPanel() {
|
||||
let initialFloatingSize = NSSize(width: 164, height: 36)
|
||||
let panel = NSPanel(
|
||||
contentRect: NSRect(origin: NSPoint(x: 80, y: 80), size: initialFloatingSize),
|
||||
styleMask: [.borderless, .nonactivatingPanel],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
panel.title = "TokenLens Floating"
|
||||
panel.level = .floating
|
||||
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
panel.isOpaque = false
|
||||
panel.backgroundColor = .clear
|
||||
panel.hasShadow = false
|
||||
panel.isMovableByWindowBackground = true
|
||||
panel.hidesOnDeactivate = false
|
||||
panel.appearance = AppSettings.shared.floatingAppearance.nsAppearance
|
||||
|
||||
let hostingView = TransparentHostingView(
|
||||
rootView: FloatingTokenWindow(store: UsageStore.shared) { [weak self] size in
|
||||
Task { @MainActor in
|
||||
self?.resizeFloatingPanel(to: size)
|
||||
}
|
||||
}
|
||||
.background(Color.clear)
|
||||
)
|
||||
makeTransparent(hostingView)
|
||||
panel.contentView = hostingView
|
||||
if let contentView = panel.contentView {
|
||||
makeTransparent(contentView)
|
||||
}
|
||||
|
||||
if let screenFrame = NSScreen.main?.visibleFrame {
|
||||
let x = screenFrame.maxX - 360
|
||||
let y = screenFrame.maxY - 170
|
||||
panel.setFrameOrigin(NSPoint(x: x, y: y))
|
||||
}
|
||||
panel.orderFrontRegardless()
|
||||
floatingPanel = panel
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func resizeFloatingPanel(to size: CGSize) {
|
||||
guard let panel = floatingPanel else { return }
|
||||
let newSize = NSSize(width: ceil(size.width), height: ceil(size.height))
|
||||
guard abs(panel.frame.width - newSize.width) > 0.5 || abs(panel.frame.height - newSize.height) > 0.5 else {
|
||||
return
|
||||
}
|
||||
let currentFrame = panel.frame
|
||||
let newFrame = NSRect(
|
||||
x: currentFrame.minX,
|
||||
y: currentFrame.maxY - newSize.height,
|
||||
width: newSize.width,
|
||||
height: newSize.height
|
||||
)
|
||||
panel.setFrame(newFrame, display: true)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func updateStatusItemVisibility(isVisible: Bool = AppSettings.shared.showStatusBarIndicator) {
|
||||
if isVisible {
|
||||
if statusItem == nil {
|
||||
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
item.button?.target = self
|
||||
item.button?.action = #selector(statusItemClicked)
|
||||
item.button?.image = NSImage(systemSymbolName: "chart.line.uptrend.xyaxis", accessibilityDescription: "Tokens")
|
||||
item.button?.imagePosition = .imageLeading
|
||||
statusItem = item
|
||||
}
|
||||
updateStatusTitle()
|
||||
} else if let item = statusItem {
|
||||
NSStatusBar.system.removeStatusItem(item)
|
||||
statusItem = nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func updateStatusTitle() {
|
||||
guard let button = statusItem?.button else { return }
|
||||
button.title = " \(TokenFormatter.format(UsageStore.shared.summary.cards.todayTokens))"
|
||||
button.toolTip = "TokenLens 今日 Tokens:\(TokenFormatter.format(UsageStore.shared.summary.cards.todayTokens))"
|
||||
}
|
||||
|
||||
@objc
|
||||
@MainActor
|
||||
private func statusItemClicked() {
|
||||
guard let button = statusItem?.button else { return }
|
||||
if statusPopover?.isShown == true {
|
||||
closeStatusPopover()
|
||||
return
|
||||
}
|
||||
let popover = NSPopover()
|
||||
popover.behavior = .transient
|
||||
popover.contentSize = statusPopoverSize()
|
||||
let controller = NSHostingController(
|
||||
rootView: StatusBarDetailView(store: UsageStore.shared)
|
||||
)
|
||||
makeTransparent(controller.view)
|
||||
popover.contentViewController = controller
|
||||
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
||||
statusPopover = popover
|
||||
applyStatusPopoverAppearance()
|
||||
scheduleStatusPopoverClose()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func scheduleStatusPopoverClose() {
|
||||
statusPopoverCloseTask?.cancel()
|
||||
let delay = UInt64(max(1, AppSettings.shared.autoCollapseSeconds)) * 1_000_000_000
|
||||
statusPopoverCloseTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: delay)
|
||||
guard !Task.isCancelled else { return }
|
||||
closeStatusPopover()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func closeStatusPopover() {
|
||||
statusPopoverCloseTask?.cancel()
|
||||
statusPopover?.close()
|
||||
statusPopover = nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func statusPopoverSize() -> NSSize {
|
||||
let activeProviderCount = Provider.allCases.filter {
|
||||
(UsageStore.shared.summary.todayProviderTotals[$0]?.totalTokens ?? 0) > 0
|
||||
}.count
|
||||
let rowCount = max(activeProviderCount, 1)
|
||||
return NSSize(width: 330, height: 128 + CGFloat(rowCount) * 54 + CGFloat(max(rowCount - 1, 0)) * 8)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyFloatingPanelAppearance() {
|
||||
floatingPanel?.appearance = AppSettings.shared.floatingAppearance.nsAppearance
|
||||
if let contentView = floatingPanel?.contentView {
|
||||
makeTransparent(contentView)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyStatusPopoverAppearance() {
|
||||
guard let popover = statusPopover else { return }
|
||||
let appearance = AppSettings.shared.floatingAppearance.nsAppearance
|
||||
popover.contentViewController?.view.window?.appearance = appearance
|
||||
if let view = popover.contentViewController?.view {
|
||||
makeTransparent(view)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeTransparent(_ view: NSView) {
|
||||
view.wantsLayer = true
|
||||
view.layer?.backgroundColor = NSColor.clear.cgColor
|
||||
view.layer?.isOpaque = false
|
||||
view.layer?.masksToBounds = false
|
||||
}
|
||||
}
|
||||
|
||||
final class TransparentHostingView<Content: View>: NSHostingView<Content> {
|
||||
override var isOpaque: Bool { false }
|
||||
|
||||
required init(rootView: Content) {
|
||||
super.init(rootView: rootView)
|
||||
prepareForTransparentCompositing()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
nil
|
||||
}
|
||||
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
prepareForTransparentCompositing()
|
||||
window?.isOpaque = false
|
||||
window?.backgroundColor = .clear
|
||||
window?.hasShadow = true
|
||||
}
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
NSColor.clear.setFill()
|
||||
dirtyRect.fill()
|
||||
super.draw(dirtyRect)
|
||||
}
|
||||
|
||||
private func prepareForTransparentCompositing() {
|
||||
wantsLayer = true
|
||||
layer?.backgroundColor = NSColor.clear.cgColor
|
||||
layer?.isOpaque = false
|
||||
layer?.masksToBounds = false
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusBarDetailView: View {
|
||||
@ObservedObject var store: UsageStore
|
||||
@StateObject private var settings = AppSettings.shared
|
||||
|
||||
private var visibleProviders: [Provider] {
|
||||
Provider.allCases.filter {
|
||||
(store.summary.todayProviderTotals[$0]?.totalTokens ?? 0) > 0
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "chart.line.uptrend.xyaxis")
|
||||
.font(.system(size: 20, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(.blue.gradient, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("今日 Tokens")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(TokenFormatter.format(store.summary.cards.todayTokens))
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
}
|
||||
Spacer()
|
||||
Text("实时")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
VStack(spacing: 8) {
|
||||
if visibleProviders.isEmpty {
|
||||
Text("今日暂无工具流量")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 46)
|
||||
.background(.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
} else {
|
||||
ForEach(visibleProviders, id: \.self) { provider in
|
||||
StatusProviderRow(
|
||||
provider: provider,
|
||||
bucket: store.summary.todayProviderTotals[provider] ?? TokenBucket(provider: provider)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
WindowActions.showMainWindow()
|
||||
} label: {
|
||||
Label("打开主界面", systemImage: "rectangle.grid.2x2")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(width: 330)
|
||||
.background(.ultraThinMaterial)
|
||||
.preferredColorScheme(settings.floatingAppearance.colorScheme)
|
||||
}
|
||||
}
|
||||
|
||||
private struct StatusProviderRow: View {
|
||||
let provider: Provider
|
||||
let bucket: TokenBucket
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 9) {
|
||||
Circle()
|
||||
.fill(provider.color)
|
||||
.frame(width: 8, height: 8)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
Text(provider.rawValue)
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
Spacer()
|
||||
Text(TokenFormatter.format(bucket.totalTokens))
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
}
|
||||
Text("输入 \(TokenFormatter.format(bucket.inputTokens)) 输出 \(TokenFormatter.format(bucket.outputTokens)) 缓存 \(TokenFormatter.format(bucket.cachedTokens))")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.background(provider.color.opacity(0.10), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
struct WindowAccessor: NSViewRepresentable {
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = NSView()
|
||||
DispatchQueue.main.async {
|
||||
guard let window = view.window else { return }
|
||||
window.title = "TokenLens"
|
||||
window.identifier = NSUserInterfaceItemIdentifier("main-dashboard-window")
|
||||
window.titleVisibility = .hidden
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.isMovableByWindowBackground = false
|
||||
window.backgroundColor = .clear
|
||||
window.isOpaque = false
|
||||
window.setContentSize(NSSize(width: 1280, height: 860))
|
||||
window.minSize = NSSize(width: 1180, height: 760)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {}
|
||||
}
|
||||
45
NativeTokenLens/Sources/TokenLens/UsageStore.swift
Normal file
45
NativeTokenLens/Sources/TokenLens/UsageStore.swift
Normal file
@@ -0,0 +1,45 @@
|
||||
import SwiftUI
|
||||
import TokenLensCore
|
||||
|
||||
@MainActor
|
||||
final class UsageStore: ObservableObject {
|
||||
static let shared = UsageStore()
|
||||
|
||||
@Published var summary = UsageSummary.make(records: [])
|
||||
@Published var isRefreshing = false
|
||||
@Published var refreshCount = 0
|
||||
|
||||
private let scanner = TokenUsageScanner()
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
|
||||
private init() {}
|
||||
|
||||
func start() {
|
||||
guard refreshTask == nil else { return }
|
||||
refreshTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.refresh()
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(15))
|
||||
await self.refresh(silent: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refresh(silent: Bool = false) async {
|
||||
if isRefreshing { return }
|
||||
if !silent { isRefreshing = true }
|
||||
let next = await scanner.scan()
|
||||
if silent {
|
||||
summary = next
|
||||
refreshCount += 1
|
||||
isRefreshing = false
|
||||
} else {
|
||||
withAnimation(.smooth(duration: 0.45)) {
|
||||
summary = next
|
||||
refreshCount += 1
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
69
NativeTokenLens/Sources/TokenLens/WindowActions.swift
Normal file
69
NativeTokenLens/Sources/TokenLens/WindowActions.swift
Normal file
@@ -0,0 +1,69 @@
|
||||
import AppKit
|
||||
|
||||
enum WindowActions {
|
||||
@MainActor
|
||||
static func zoomActiveWindow() {
|
||||
guard let window = NSApp.keyWindow ?? NSApp.mainWindow else { return }
|
||||
toggleMaximize(window)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func toggleMaximize(_ window: NSWindow) {
|
||||
guard let visibleFrame = window.screen?.visibleFrame ?? NSScreen.main?.visibleFrame else {
|
||||
return
|
||||
}
|
||||
|
||||
let currentFrame = window.frame
|
||||
let isMaximized =
|
||||
abs(currentFrame.minX - visibleFrame.minX) < 2 &&
|
||||
abs(currentFrame.minY - visibleFrame.minY) < 2 &&
|
||||
abs(currentFrame.width - visibleFrame.width) < 2 &&
|
||||
abs(currentFrame.height - visibleFrame.height) < 2
|
||||
|
||||
if isMaximized {
|
||||
let restoredSize = NSSize(
|
||||
width: min(max(window.minSize.width, 1280), visibleFrame.width),
|
||||
height: min(max(window.minSize.height, 860), visibleFrame.height)
|
||||
)
|
||||
let restoredFrame = NSRect(
|
||||
x: visibleFrame.midX - restoredSize.width / 2,
|
||||
y: visibleFrame.midY - restoredSize.height / 2,
|
||||
width: restoredSize.width,
|
||||
height: restoredSize.height
|
||||
)
|
||||
window.setFrame(restoredFrame, display: true, animate: false)
|
||||
} else {
|
||||
window.setFrame(visibleFrame, display: true, animate: false)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func showMainWindow() {
|
||||
if let appDelegate = NSApp.delegate as? AppDelegate {
|
||||
appDelegate.showDashboardWindow()
|
||||
return
|
||||
}
|
||||
|
||||
let mainWindow = NSApp.windows.first { window in
|
||||
window.identifier?.rawValue == "main-dashboard-window"
|
||||
} ?? NSApp.windows.first { window in
|
||||
!(window is NSPanel) && window.title == "TokenLens"
|
||||
} ?? NSApp.windows.first { window in
|
||||
!(window is NSPanel) && !window.title.contains("设置")
|
||||
}
|
||||
|
||||
NSApp.windows
|
||||
.filter { $0 !== mainWindow && $0.title.contains("设置") }
|
||||
.forEach { $0.orderOut(nil) }
|
||||
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
NSApp.unhide(nil)
|
||||
if mainWindow?.isMiniaturized == true {
|
||||
mainWindow?.deminiaturize(nil)
|
||||
}
|
||||
guard let mainWindow else { return }
|
||||
mainWindow.makeKeyAndOrderFront(nil)
|
||||
mainWindow.makeMain()
|
||||
mainWindow.orderFrontRegardless()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import Darwin
|
||||
|
||||
public enum MemoryPressure {
|
||||
public static func relieve() {
|
||||
_ = malloc_zone_pressure_relief(nil, 0)
|
||||
}
|
||||
}
|
||||
206
NativeTokenLens/Sources/TokenLensCore/Models.swift
Normal file
206
NativeTokenLens/Sources/TokenLensCore/Models.swift
Normal file
@@ -0,0 +1,206 @@
|
||||
import Foundation
|
||||
|
||||
public enum Provider: String, CaseIterable, Codable, Hashable, Sendable {
|
||||
case claude = "Claude"
|
||||
case codex = "Codex"
|
||||
case gemini = "Gemini"
|
||||
case hermes = "Hermes"
|
||||
case opencode = "OpenCode"
|
||||
}
|
||||
|
||||
public struct UsageRecord: Identifiable, Hashable, Sendable {
|
||||
public let id: UUID
|
||||
public let provider: Provider
|
||||
public let source: String
|
||||
public let sourcePath: String
|
||||
public let timestamp: Date
|
||||
public let sessionID: String
|
||||
public let model: String
|
||||
public let inputTokens: Int
|
||||
public let outputTokens: Int
|
||||
public let cachedTokens: Int
|
||||
public let reasoningTokens: Int
|
||||
public let toolTokens: Int
|
||||
public let totalTokens: Int
|
||||
|
||||
public init(
|
||||
id: UUID = UUID(),
|
||||
provider: Provider,
|
||||
source: String,
|
||||
sourcePath: String,
|
||||
timestamp: Date,
|
||||
sessionID: String,
|
||||
model: String,
|
||||
inputTokens: Int,
|
||||
outputTokens: Int,
|
||||
cachedTokens: Int,
|
||||
reasoningTokens: Int,
|
||||
toolTokens: Int,
|
||||
explicitTotal: Int? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.provider = provider
|
||||
self.source = source
|
||||
self.sourcePath = sourcePath
|
||||
self.timestamp = timestamp
|
||||
self.sessionID = sessionID
|
||||
self.model = model
|
||||
self.inputTokens = inputTokens
|
||||
self.outputTokens = outputTokens
|
||||
self.cachedTokens = cachedTokens
|
||||
self.reasoningTokens = reasoningTokens
|
||||
self.toolTokens = toolTokens
|
||||
self.totalTokens = explicitTotal ?? inputTokens + outputTokens + cachedTokens + reasoningTokens + toolTokens
|
||||
}
|
||||
}
|
||||
|
||||
public struct TokenBucket: Hashable, Sendable {
|
||||
public let provider: Provider?
|
||||
public var inputTokens: Int = 0
|
||||
public var outputTokens: Int = 0
|
||||
public var cachedTokens: Int = 0
|
||||
public var reasoningTokens: Int = 0
|
||||
public var toolTokens: Int = 0
|
||||
public var totalTokens: Int = 0
|
||||
public var count: Int = 0
|
||||
|
||||
public init(provider: Provider? = nil) {
|
||||
self.provider = provider
|
||||
}
|
||||
|
||||
public mutating func add(_ record: UsageRecord) {
|
||||
add(
|
||||
inputTokens: record.inputTokens,
|
||||
outputTokens: record.outputTokens,
|
||||
cachedTokens: record.cachedTokens,
|
||||
reasoningTokens: record.reasoningTokens,
|
||||
toolTokens: record.toolTokens,
|
||||
totalTokens: record.totalTokens
|
||||
)
|
||||
}
|
||||
|
||||
public mutating func add(
|
||||
inputTokens: Int,
|
||||
outputTokens: Int,
|
||||
cachedTokens: Int,
|
||||
reasoningTokens: Int,
|
||||
toolTokens: Int,
|
||||
totalTokens: Int
|
||||
) {
|
||||
self.inputTokens += inputTokens
|
||||
self.outputTokens += outputTokens
|
||||
self.cachedTokens += cachedTokens
|
||||
self.reasoningTokens += reasoningTokens
|
||||
self.toolTokens += toolTokens
|
||||
self.totalTokens += totalTokens
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
|
||||
public struct SummaryCards: Hashable, Sendable {
|
||||
public let todayTokens: Int
|
||||
public let monthTokens: Int
|
||||
public let totalTokens: Int
|
||||
public let cacheHitRate: Int
|
||||
public let sessionCount: Int
|
||||
}
|
||||
|
||||
public struct DailyPoint: Identifiable, Hashable, Sendable {
|
||||
public let id = UUID()
|
||||
public let date: Date
|
||||
public let values: [Provider: Int]
|
||||
}
|
||||
|
||||
public enum TrendMode: String, CaseIterable, Sendable {
|
||||
case daily = "每日"
|
||||
case weekly = "每周"
|
||||
case monthly = "每月"
|
||||
case yearly = "每年"
|
||||
}
|
||||
|
||||
public struct ToolRow: Identifiable, Hashable, Sendable {
|
||||
public let id = UUID()
|
||||
public let provider: Provider
|
||||
public let inputTokens: Int
|
||||
public let outputTokens: Int
|
||||
public let cachedTokens: Int
|
||||
public let totalTokens: Int
|
||||
public let trendTokens: [Int]
|
||||
}
|
||||
|
||||
public struct DailyUsageDetail: Identifiable, Hashable, Sendable {
|
||||
public var id: Date { date }
|
||||
|
||||
public let date: Date
|
||||
public var inputTokens: Int
|
||||
public var outputTokens: Int
|
||||
public var cachedTokens: Int
|
||||
public var totalTokens: Int
|
||||
public var providerTotals: [Provider: Int]
|
||||
|
||||
public init(
|
||||
date: Date,
|
||||
inputTokens: Int = 0,
|
||||
outputTokens: Int = 0,
|
||||
cachedTokens: Int = 0,
|
||||
totalTokens: Int = 0,
|
||||
providerTotals: [Provider: Int] = [:]
|
||||
) {
|
||||
self.date = date
|
||||
self.inputTokens = inputTokens
|
||||
self.outputTokens = outputTokens
|
||||
self.cachedTokens = cachedTokens
|
||||
self.totalTokens = totalTokens
|
||||
self.providerTotals = providerTotals
|
||||
}
|
||||
|
||||
public mutating func add(_ record: UsageRecord) {
|
||||
add(
|
||||
provider: record.provider,
|
||||
inputTokens: record.inputTokens,
|
||||
outputTokens: record.outputTokens,
|
||||
cachedTokens: record.cachedTokens,
|
||||
totalTokens: record.totalTokens
|
||||
)
|
||||
}
|
||||
|
||||
public mutating func add(
|
||||
provider: Provider,
|
||||
inputTokens: Int,
|
||||
outputTokens: Int,
|
||||
cachedTokens: Int,
|
||||
totalTokens: Int
|
||||
) {
|
||||
self.inputTokens += inputTokens
|
||||
self.outputTokens += outputTokens
|
||||
self.cachedTokens += cachedTokens
|
||||
self.totalTokens += totalTokens
|
||||
providerTotals[provider, default: 0] += totalTokens
|
||||
}
|
||||
}
|
||||
|
||||
public struct Insight: Identifiable, Hashable, Sendable {
|
||||
public enum Tone: String, Sendable {
|
||||
case warning
|
||||
case positive
|
||||
case accent
|
||||
}
|
||||
|
||||
public let id = UUID()
|
||||
public let tone: Tone
|
||||
public let title: String
|
||||
public let message: String
|
||||
}
|
||||
|
||||
public struct UsageSummary: Sendable {
|
||||
public let generatedAt: Date
|
||||
public let records: [UsageRecord]
|
||||
public let cards: SummaryCards
|
||||
public let providerTotals: [Provider: TokenBucket]
|
||||
public let todayProviderTotals: [Provider: TokenBucket]
|
||||
public let dailyTrend: [DailyPoint]
|
||||
public let trendPointsByMode: [TrendMode: [DailyPoint]]
|
||||
public let dailyDetails: [DailyUsageDetail]
|
||||
public let toolRows: [ToolRow]
|
||||
public let insights: [Insight]
|
||||
}
|
||||
581
NativeTokenLens/Sources/TokenLensCore/SQLiteUsageCache.swift
Normal file
581
NativeTokenLens/Sources/TokenLensCore/SQLiteUsageCache.swift
Normal file
@@ -0,0 +1,581 @@
|
||||
import Foundation
|
||||
import SQLite3
|
||||
|
||||
enum SQLiteUsageCacheError: Error {
|
||||
case openFailed(String)
|
||||
case prepareFailed(String)
|
||||
case executeFailed(String)
|
||||
case bindFailed(String)
|
||||
case stepFailed(String)
|
||||
}
|
||||
|
||||
final class SQLiteUsageCache {
|
||||
private static let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
||||
|
||||
private let database: OpaquePointer?
|
||||
private var calendar: Calendar = {
|
||||
var calendar = Calendar.current
|
||||
calendar.firstWeekday = 2
|
||||
return calendar
|
||||
}()
|
||||
|
||||
init(url: URL) throws {
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
var database: OpaquePointer?
|
||||
let flags = SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX
|
||||
guard sqlite3_open_v2(url.path, &database, flags, nil) == SQLITE_OK else {
|
||||
let message = database.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "无法打开 SQLite"
|
||||
sqlite3_close(database)
|
||||
throw SQLiteUsageCacheError.openFailed(message)
|
||||
}
|
||||
self.database = database
|
||||
|
||||
try configure()
|
||||
try migrate()
|
||||
}
|
||||
|
||||
deinit {
|
||||
sqlite3_close(database)
|
||||
}
|
||||
|
||||
func isFileCurrent(path: String, size: Int64, modifiedAt: TimeInterval) throws -> Bool {
|
||||
let statement = try prepare("SELECT size, modified_at, scanned_offset FROM files WHERE path = ? LIMIT 1")
|
||||
defer { sqlite3_finalize(statement) }
|
||||
|
||||
try bind(path, to: statement, at: 1)
|
||||
guard sqlite3_step(statement) == SQLITE_ROW else {
|
||||
return false
|
||||
}
|
||||
|
||||
let cachedSize = sqlite3_column_int64(statement, 0)
|
||||
let cachedModifiedAt = sqlite3_column_double(statement, 1)
|
||||
let scannedOffset = sqlite3_column_int64(statement, 2)
|
||||
return cachedSize == size && scannedOffset == size && abs(cachedModifiedAt - modifiedAt) < 0.001
|
||||
}
|
||||
|
||||
func fileState(path: String) throws -> CachedFileState? {
|
||||
let statement = try prepare("""
|
||||
SELECT size, modified_at, scanned_offset, record_count
|
||||
FROM files
|
||||
WHERE path = ?
|
||||
LIMIT 1
|
||||
""")
|
||||
defer { sqlite3_finalize(statement) }
|
||||
|
||||
try bind(path, to: statement, at: 1)
|
||||
guard sqlite3_step(statement) == SQLITE_ROW else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return CachedFileState(
|
||||
size: sqlite3_column_int64(statement, 0),
|
||||
modifiedAt: sqlite3_column_double(statement, 1),
|
||||
scannedOffset: sqlite3_column_int64(statement, 2),
|
||||
recordCount: Int(sqlite3_column_int64(statement, 3))
|
||||
)
|
||||
}
|
||||
|
||||
func replaceFileRecords(
|
||||
path: String,
|
||||
size: Int64,
|
||||
modifiedAt: TimeInterval,
|
||||
scan: (_ insert: (UsageRecord) throws -> Void) throws -> Int64
|
||||
) throws {
|
||||
try execute("BEGIN IMMEDIATE TRANSACTION")
|
||||
do {
|
||||
try deleteRecords(path: path)
|
||||
let insertStatement = try prepare("""
|
||||
INSERT INTO records (
|
||||
id, provider, source_path, timestamp, session_id, model,
|
||||
input_tokens, output_tokens, cached_tokens, reasoning_tokens,
|
||||
tool_tokens, total_tokens, day_start, hour_start, month_start
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""")
|
||||
defer { sqlite3_finalize(insertStatement) }
|
||||
|
||||
var recordCount = 0
|
||||
let scannedOffset = try scan { record in
|
||||
recordCount += 1
|
||||
try self.insert(record, id: "\(path)#\(recordCount)", statement: insertStatement)
|
||||
}
|
||||
try upsertFile(
|
||||
path: path,
|
||||
size: size,
|
||||
modifiedAt: modifiedAt,
|
||||
scannedOffset: scannedOffset,
|
||||
recordCount: recordCount
|
||||
)
|
||||
try execute("COMMIT")
|
||||
} catch {
|
||||
try? execute("ROLLBACK")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func appendFileRecords(
|
||||
path: String,
|
||||
size: Int64,
|
||||
modifiedAt: TimeInterval,
|
||||
startingRecordCount: Int,
|
||||
scan: (_ insert: (UsageRecord) throws -> Void) throws -> Int64
|
||||
) throws {
|
||||
try execute("BEGIN IMMEDIATE TRANSACTION")
|
||||
do {
|
||||
let insertStatement = try prepare("""
|
||||
INSERT INTO records (
|
||||
id, provider, source_path, timestamp, session_id, model,
|
||||
input_tokens, output_tokens, cached_tokens, reasoning_tokens,
|
||||
tool_tokens, total_tokens, day_start, hour_start, month_start
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""")
|
||||
defer { sqlite3_finalize(insertStatement) }
|
||||
|
||||
var appendedCount = 0
|
||||
let scannedOffset = try scan { record in
|
||||
appendedCount += 1
|
||||
let nextRecordIndex = startingRecordCount + appendedCount
|
||||
try self.insert(record, id: "\(path)#\(nextRecordIndex)", statement: insertStatement)
|
||||
}
|
||||
try upsertFile(
|
||||
path: path,
|
||||
size: size,
|
||||
modifiedAt: modifiedAt,
|
||||
scannedOffset: scannedOffset,
|
||||
recordCount: startingRecordCount + appendedCount
|
||||
)
|
||||
try execute("COMMIT")
|
||||
} catch {
|
||||
try? execute("ROLLBACK")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func pruneMissingFiles(currentPaths: Set<String>) throws {
|
||||
let statement = try prepare("SELECT path FROM files")
|
||||
defer { sqlite3_finalize(statement) }
|
||||
|
||||
var cachedPaths: [String] = []
|
||||
while sqlite3_step(statement) == SQLITE_ROW {
|
||||
cachedPaths.append(columnText(statement, at: 0))
|
||||
}
|
||||
|
||||
for path in cachedPaths where !currentPaths.contains(path) {
|
||||
try deleteRecords(path: path)
|
||||
let deleteFile = try prepare("DELETE FROM files WHERE path = ?")
|
||||
defer { sqlite3_finalize(deleteFile) }
|
||||
try bind(path, to: deleteFile, at: 1)
|
||||
try stepDone(deleteFile)
|
||||
}
|
||||
}
|
||||
|
||||
func makeSummary(now: Date = Date(), calendar: Calendar = Calendar.current) throws -> UsageSummary {
|
||||
self.calendar = calendarWithMondayStart(calendar)
|
||||
|
||||
let providerTotals = try providerBuckets()
|
||||
let todayProviderTotals = try providerBuckets(
|
||||
whereClause: "timestamp >= ? AND timestamp < ?",
|
||||
bindings: dayBounds(for: now).bindings
|
||||
)
|
||||
let dailyDetails = try dailyUsageDetails()
|
||||
let dailyTrend = dailyDetails
|
||||
.sorted { $0.date < $1.date }
|
||||
.suffix(30)
|
||||
.map { detail in
|
||||
DailyPoint(date: detail.date, values: detail.providerTotals)
|
||||
}
|
||||
let trendPointsByMode = try Dictionary(uniqueKeysWithValues: TrendMode.allCases.map { mode in
|
||||
(mode, try trendPoints(mode: mode, now: now))
|
||||
})
|
||||
let toolRows = Provider.allCases.compactMap { provider -> ToolRow? in
|
||||
guard let bucket = providerTotals[provider], bucket.totalTokens > 0 else { return nil }
|
||||
return ToolRow(
|
||||
provider: provider,
|
||||
inputTokens: bucket.inputTokens,
|
||||
outputTokens: bucket.outputTokens,
|
||||
cachedTokens: bucket.cachedTokens,
|
||||
totalTokens: bucket.totalTokens,
|
||||
trendTokens: dailyTrend.map { $0.values[provider, default: 0] }
|
||||
)
|
||||
}
|
||||
.sorted { $0.totalTokens > $1.totalTokens }
|
||||
|
||||
let todayTokens = todayProviderTotals.values.reduce(0) { $0 + $1.totalTokens }
|
||||
let monthTokens = try tokenSum(whereClause: "timestamp >= ? AND timestamp < ?", bindings: monthBounds(for: now).bindings)
|
||||
let totalTokens = providerTotals.values.reduce(0) { $0 + $1.totalTokens }
|
||||
let cachedTokens = providerTotals.values.reduce(0) { $0 + $1.cachedTokens }
|
||||
let cacheHitRate = totalTokens > 0 ? Int((Double(cachedTokens) / Double(totalTokens) * 100).rounded()) : 0
|
||||
let cards = SummaryCards(
|
||||
todayTokens: todayTokens,
|
||||
monthTokens: monthTokens,
|
||||
totalTokens: totalTokens,
|
||||
cacheHitRate: cacheHitRate,
|
||||
sessionCount: try sessionCount()
|
||||
)
|
||||
|
||||
return UsageSummary(
|
||||
generatedAt: now,
|
||||
records: [],
|
||||
cards: cards,
|
||||
providerTotals: providerTotals,
|
||||
todayProviderTotals: todayProviderTotals,
|
||||
dailyTrend: Array(dailyTrend),
|
||||
trendPointsByMode: trendPointsByMode,
|
||||
dailyDetails: dailyDetails.sorted { $0.date > $1.date },
|
||||
toolRows: toolRows,
|
||||
insights: UsageSummary.makeInsights(
|
||||
providerTotals: providerTotals,
|
||||
todayTokens: todayTokens,
|
||||
cacheHitRate: cacheHitRate
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func configure() throws {
|
||||
try execute("PRAGMA journal_mode = WAL")
|
||||
try execute("PRAGMA synchronous = NORMAL")
|
||||
try execute("PRAGMA temp_store = MEMORY")
|
||||
try execute("PRAGMA busy_timeout = 3000")
|
||||
}
|
||||
|
||||
private func migrate() throws {
|
||||
if try recordsTableNeedsRebuild() {
|
||||
try execute("DROP TABLE IF EXISTS records")
|
||||
try execute("DROP TABLE IF EXISTS files")
|
||||
}
|
||||
try execute("""
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
path TEXT PRIMARY KEY,
|
||||
size INTEGER NOT NULL,
|
||||
modified_at REAL NOT NULL,
|
||||
scanned_offset INTEGER NOT NULL DEFAULT 0,
|
||||
record_count INTEGER NOT NULL DEFAULT 0,
|
||||
scanned_at REAL NOT NULL
|
||||
)
|
||||
""")
|
||||
try addColumnIfMissing(
|
||||
table: "files",
|
||||
column: "scanned_offset",
|
||||
definition: "scanned_offset INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
try addColumnIfMissing(
|
||||
table: "files",
|
||||
column: "record_count",
|
||||
definition: "record_count INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
try execute("""
|
||||
CREATE TABLE IF NOT EXISTS records (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
source_path TEXT NOT NULL,
|
||||
timestamp REAL NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
input_tokens INTEGER NOT NULL,
|
||||
output_tokens INTEGER NOT NULL,
|
||||
cached_tokens INTEGER NOT NULL,
|
||||
reasoning_tokens INTEGER NOT NULL,
|
||||
tool_tokens INTEGER NOT NULL,
|
||||
total_tokens INTEGER NOT NULL,
|
||||
day_start REAL NOT NULL,
|
||||
hour_start REAL NOT NULL,
|
||||
month_start REAL NOT NULL
|
||||
)
|
||||
""")
|
||||
try execute("CREATE INDEX IF NOT EXISTS records_provider_idx ON records(provider)")
|
||||
try execute("CREATE INDEX IF NOT EXISTS records_source_path_idx ON records(source_path)")
|
||||
try execute("CREATE INDEX IF NOT EXISTS records_timestamp_idx ON records(timestamp)")
|
||||
try execute("CREATE INDEX IF NOT EXISTS records_day_provider_idx ON records(day_start, provider)")
|
||||
try execute("CREATE INDEX IF NOT EXISTS records_hour_provider_idx ON records(hour_start, provider)")
|
||||
try execute("CREATE INDEX IF NOT EXISTS records_month_provider_idx ON records(month_start, provider)")
|
||||
}
|
||||
|
||||
private func deleteRecords(path: String) throws {
|
||||
let statement = try prepare("DELETE FROM records WHERE source_path = ?")
|
||||
defer { sqlite3_finalize(statement) }
|
||||
try bind(path, to: statement, at: 1)
|
||||
try stepDone(statement)
|
||||
}
|
||||
|
||||
private func upsertFile(
|
||||
path: String,
|
||||
size: Int64,
|
||||
modifiedAt: TimeInterval,
|
||||
scannedOffset: Int64,
|
||||
recordCount: Int
|
||||
) throws {
|
||||
let statement = try prepare("""
|
||||
INSERT INTO files (path, size, modified_at, scanned_offset, record_count, scanned_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
size = excluded.size,
|
||||
modified_at = excluded.modified_at,
|
||||
scanned_offset = excluded.scanned_offset,
|
||||
record_count = excluded.record_count,
|
||||
scanned_at = excluded.scanned_at
|
||||
""")
|
||||
defer { sqlite3_finalize(statement) }
|
||||
|
||||
try bind(path, to: statement, at: 1)
|
||||
sqlite3_bind_int64(statement, 2, size)
|
||||
sqlite3_bind_double(statement, 3, modifiedAt)
|
||||
sqlite3_bind_int64(statement, 4, scannedOffset)
|
||||
sqlite3_bind_int64(statement, 5, Int64(recordCount))
|
||||
sqlite3_bind_double(statement, 6, Date().timeIntervalSince1970)
|
||||
try stepDone(statement)
|
||||
}
|
||||
|
||||
private func insert(_ record: UsageRecord, id: String, statement: OpaquePointer?) throws {
|
||||
sqlite3_reset(statement)
|
||||
sqlite3_clear_bindings(statement)
|
||||
|
||||
try bind(id, to: statement, at: 1)
|
||||
try bind(record.provider.rawValue, to: statement, at: 2)
|
||||
try bind(record.sourcePath, to: statement, at: 3)
|
||||
sqlite3_bind_double(statement, 4, record.timestamp.timeIntervalSince1970)
|
||||
try bind(record.sessionID, to: statement, at: 5)
|
||||
try bind(record.model, to: statement, at: 6)
|
||||
sqlite3_bind_int64(statement, 7, Int64(record.inputTokens))
|
||||
sqlite3_bind_int64(statement, 8, Int64(record.outputTokens))
|
||||
sqlite3_bind_int64(statement, 9, Int64(record.cachedTokens))
|
||||
sqlite3_bind_int64(statement, 10, Int64(record.reasoningTokens))
|
||||
sqlite3_bind_int64(statement, 11, Int64(record.toolTokens))
|
||||
sqlite3_bind_int64(statement, 12, Int64(record.totalTokens))
|
||||
sqlite3_bind_double(statement, 13, calendar.startOfDay(for: record.timestamp).timeIntervalSince1970)
|
||||
sqlite3_bind_double(statement, 14, hourStart(for: record.timestamp).timeIntervalSince1970)
|
||||
sqlite3_bind_double(statement, 15, monthStart(for: record.timestamp).timeIntervalSince1970)
|
||||
try stepDone(statement)
|
||||
}
|
||||
|
||||
private func providerBuckets(
|
||||
whereClause: String? = nil,
|
||||
bindings: [TimeInterval] = []
|
||||
) throws -> [Provider: TokenBucket] {
|
||||
var buckets = Dictionary(uniqueKeysWithValues: Provider.allCases.map {
|
||||
($0, TokenBucket(provider: $0))
|
||||
})
|
||||
var sql = """
|
||||
SELECT provider,
|
||||
SUM(input_tokens), SUM(output_tokens), SUM(cached_tokens),
|
||||
SUM(reasoning_tokens), SUM(tool_tokens), SUM(total_tokens), COUNT(*)
|
||||
FROM records
|
||||
"""
|
||||
if let whereClause {
|
||||
sql += " WHERE \(whereClause)"
|
||||
}
|
||||
sql += " GROUP BY provider"
|
||||
|
||||
let statement = try prepare(sql)
|
||||
defer { sqlite3_finalize(statement) }
|
||||
try bindIntervals(bindings, to: statement)
|
||||
|
||||
while sqlite3_step(statement) == SQLITE_ROW {
|
||||
guard let provider = Provider(rawValue: columnText(statement, at: 0)) else { continue }
|
||||
var bucket = TokenBucket(provider: provider)
|
||||
bucket.inputTokens = Int(sqlite3_column_int64(statement, 1))
|
||||
bucket.outputTokens = Int(sqlite3_column_int64(statement, 2))
|
||||
bucket.cachedTokens = Int(sqlite3_column_int64(statement, 3))
|
||||
bucket.reasoningTokens = Int(sqlite3_column_int64(statement, 4))
|
||||
bucket.toolTokens = Int(sqlite3_column_int64(statement, 5))
|
||||
bucket.totalTokens = Int(sqlite3_column_int64(statement, 6))
|
||||
bucket.count = Int(sqlite3_column_int64(statement, 7))
|
||||
buckets[provider] = bucket
|
||||
}
|
||||
|
||||
return buckets
|
||||
}
|
||||
|
||||
private func dailyUsageDetails() throws -> [DailyUsageDetail] {
|
||||
let statement = try prepare("""
|
||||
SELECT day_start, provider,
|
||||
SUM(input_tokens), SUM(output_tokens), SUM(cached_tokens), SUM(total_tokens)
|
||||
FROM records
|
||||
GROUP BY day_start, provider
|
||||
ORDER BY day_start DESC
|
||||
""")
|
||||
defer { sqlite3_finalize(statement) }
|
||||
|
||||
var details: [Date: DailyUsageDetail] = [:]
|
||||
while sqlite3_step(statement) == SQLITE_ROW {
|
||||
guard let provider = Provider(rawValue: columnText(statement, at: 1)) else { continue }
|
||||
let date = Date(timeIntervalSince1970: sqlite3_column_double(statement, 0))
|
||||
if details[date] == nil {
|
||||
details[date] = DailyUsageDetail(date: date)
|
||||
}
|
||||
details[date]?.add(
|
||||
provider: provider,
|
||||
inputTokens: Int(sqlite3_column_int64(statement, 2)),
|
||||
outputTokens: Int(sqlite3_column_int64(statement, 3)),
|
||||
cachedTokens: Int(sqlite3_column_int64(statement, 4)),
|
||||
totalTokens: Int(sqlite3_column_int64(statement, 5))
|
||||
)
|
||||
}
|
||||
|
||||
return details.values.sorted { $0.date > $1.date }
|
||||
}
|
||||
|
||||
private func trendPoints(mode: TrendMode, now: Date) throws -> [DailyPoint] {
|
||||
let reference = mode.periodStart(for: now, calendar: calendar)
|
||||
let start = mode.startDate(reference: reference, calendar: calendar)
|
||||
let column = mode.periodColumn
|
||||
let statement = try prepare("""
|
||||
SELECT \(column), provider, SUM(total_tokens)
|
||||
FROM records
|
||||
WHERE \(column) >= ? AND \(column) <= ?
|
||||
GROUP BY \(column), provider
|
||||
""")
|
||||
defer { sqlite3_finalize(statement) }
|
||||
sqlite3_bind_double(statement, 1, start.timeIntervalSince1970)
|
||||
sqlite3_bind_double(statement, 2, reference.timeIntervalSince1970)
|
||||
|
||||
var buckets: [Date: [Provider: Int]] = [:]
|
||||
while sqlite3_step(statement) == SQLITE_ROW {
|
||||
guard let provider = Provider(rawValue: columnText(statement, at: 1)) else { continue }
|
||||
let date = Date(timeIntervalSince1970: sqlite3_column_double(statement, 0))
|
||||
buckets[date, default: Provider.zeroTokenMap][provider, default: 0] = Int(sqlite3_column_int64(statement, 2))
|
||||
}
|
||||
|
||||
var points: [DailyPoint] = []
|
||||
var cursor = start
|
||||
while cursor <= reference {
|
||||
points.append(DailyPoint(date: cursor, values: buckets[cursor, default: Provider.zeroTokenMap]))
|
||||
guard let next = calendar.date(byAdding: mode.calendarComponent, value: 1, to: cursor) else { break }
|
||||
cursor = next
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
private func tokenSum(whereClause: String, bindings: [TimeInterval]) throws -> Int {
|
||||
let statement = try prepare("SELECT COALESCE(SUM(total_tokens), 0) FROM records WHERE \(whereClause)")
|
||||
defer { sqlite3_finalize(statement) }
|
||||
try bindIntervals(bindings, to: statement)
|
||||
guard sqlite3_step(statement) == SQLITE_ROW else { return 0 }
|
||||
return Int(sqlite3_column_int64(statement, 0))
|
||||
}
|
||||
|
||||
private func sessionCount() throws -> Int {
|
||||
let statement = try prepare("""
|
||||
SELECT COUNT(DISTINCT CASE WHEN session_id = '' THEN source_path ELSE session_id END)
|
||||
FROM records
|
||||
""")
|
||||
defer { sqlite3_finalize(statement) }
|
||||
guard sqlite3_step(statement) == SQLITE_ROW else { return 0 }
|
||||
return Int(sqlite3_column_int64(statement, 0))
|
||||
}
|
||||
|
||||
private func recordsTableNeedsRebuild() throws -> Bool {
|
||||
let columns = try tableColumns("records")
|
||||
return !columns.isEmpty && !columns.contains("day_start")
|
||||
}
|
||||
|
||||
private func addColumnIfMissing(table: String, column: String, definition: String) throws {
|
||||
let columns = try tableColumns(table)
|
||||
guard !columns.isEmpty, !columns.contains(column) else { return }
|
||||
try execute("ALTER TABLE \(table) ADD COLUMN \(definition)")
|
||||
}
|
||||
|
||||
private func tableColumns(_ table: String) throws -> Set<String> {
|
||||
let statement = try prepare("PRAGMA table_info(\(table))")
|
||||
defer { sqlite3_finalize(statement) }
|
||||
|
||||
var columns = Set<String>()
|
||||
while sqlite3_step(statement) == SQLITE_ROW {
|
||||
columns.insert(columnText(statement, at: 1))
|
||||
}
|
||||
return columns
|
||||
}
|
||||
|
||||
private func bindIntervals(_ values: [TimeInterval], to statement: OpaquePointer?) throws {
|
||||
for (index, value) in values.enumerated() {
|
||||
sqlite3_bind_double(statement, Int32(index + 1), value)
|
||||
}
|
||||
}
|
||||
|
||||
private func dayBounds(for date: Date) -> QueryBounds {
|
||||
let start = calendar.startOfDay(for: date)
|
||||
let end = calendar.date(byAdding: .day, value: 1, to: start) ?? date
|
||||
return QueryBounds(start: start, end: end)
|
||||
}
|
||||
|
||||
private func monthBounds(for date: Date) -> QueryBounds {
|
||||
let start = monthStart(for: date)
|
||||
let end = calendar.date(byAdding: .month, value: 1, to: start) ?? date
|
||||
return QueryBounds(start: start, end: end)
|
||||
}
|
||||
|
||||
private func hourStart(for date: Date) -> Date {
|
||||
let components = calendar.dateComponents([.year, .month, .day, .hour], from: date)
|
||||
return calendar.date(from: components) ?? calendar.startOfDay(for: date)
|
||||
}
|
||||
|
||||
private func monthStart(for date: Date) -> Date {
|
||||
let components = calendar.dateComponents([.year, .month], from: date)
|
||||
return calendar.date(from: components) ?? calendar.startOfDay(for: date)
|
||||
}
|
||||
|
||||
private func calendarWithMondayStart(_ calendar: Calendar) -> Calendar {
|
||||
var copy = calendar
|
||||
copy.firstWeekday = 2
|
||||
return copy
|
||||
}
|
||||
|
||||
private func prepare(_ sql: String) throws -> OpaquePointer? {
|
||||
var statement: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else {
|
||||
throw SQLiteUsageCacheError.prepareFailed(errorMessage)
|
||||
}
|
||||
return statement
|
||||
}
|
||||
|
||||
private func execute(_ sql: String) throws {
|
||||
guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else {
|
||||
throw SQLiteUsageCacheError.executeFailed(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private func bind(_ text: String, to statement: OpaquePointer?, at index: Int32) throws {
|
||||
let result = text.withCString {
|
||||
sqlite3_bind_text(statement, index, $0, -1, Self.transient)
|
||||
}
|
||||
guard result == SQLITE_OK else {
|
||||
throw SQLiteUsageCacheError.bindFailed(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private func stepDone(_ statement: OpaquePointer?) throws {
|
||||
guard sqlite3_step(statement) == SQLITE_DONE else {
|
||||
throw SQLiteUsageCacheError.stepFailed(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private func columnText(_ statement: OpaquePointer?, at index: Int32) -> String {
|
||||
guard let text = sqlite3_column_text(statement, index) else {
|
||||
return ""
|
||||
}
|
||||
return String(cString: UnsafeRawPointer(text).assumingMemoryBound(to: CChar.self))
|
||||
}
|
||||
|
||||
private var errorMessage: String {
|
||||
database.flatMap { String(cString: sqlite3_errmsg($0)) } ?? "SQLite 操作失败"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct CachedFileState {
|
||||
let size: Int64
|
||||
let modifiedAt: TimeInterval
|
||||
let scannedOffset: Int64
|
||||
let recordCount: Int
|
||||
}
|
||||
|
||||
private struct QueryBounds {
|
||||
let start: Date
|
||||
let end: Date
|
||||
|
||||
var bindings: [TimeInterval] {
|
||||
[start.timeIntervalSince1970, end.timeIntervalSince1970]
|
||||
}
|
||||
}
|
||||
19
NativeTokenLens/Sources/TokenLensCore/TokenFormatter.swift
Normal file
19
NativeTokenLens/Sources/TokenLensCore/TokenFormatter.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
public enum TokenFormatter {
|
||||
public static func format(_ value: Int) -> String {
|
||||
if value >= 100_000_000 {
|
||||
return adaptive(Double(value) / 100_000_000) + "亿"
|
||||
}
|
||||
if value >= 10_000 {
|
||||
return adaptive(Double(value) / 10_000) + "万"
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
private static func adaptive(_ value: Double) -> String {
|
||||
let integerDigits = String(Int(abs(value.rounded(.towardZero)))).count
|
||||
let digits = integerDigits > 3 ? 1 : 3
|
||||
return String(format: "%.\(digits)f", value)
|
||||
}
|
||||
}
|
||||
105
NativeTokenLens/Sources/TokenLensCore/TokenUsageParser.swift
Normal file
105
NativeTokenLens/Sources/TokenLensCore/TokenUsageParser.swift
Normal file
@@ -0,0 +1,105 @@
|
||||
import Foundation
|
||||
|
||||
public enum TokenUsageParser {
|
||||
public static func parseClaudeLine(_ line: String, sourcePath: String) -> UsageRecord? {
|
||||
guard let object = parseJSONObject(line) else { return nil }
|
||||
let message = object["message"] as? [String: Any]
|
||||
let usage = message?["usage"] as? [String: Any] ?? object["usage"] as? [String: Any]
|
||||
guard let usage else { return nil }
|
||||
guard let timestamp = parseDate(object["timestamp"] ?? object["createdAt"] ?? object["date"]) else { return nil }
|
||||
|
||||
return UsageRecord(
|
||||
provider: .claude,
|
||||
source: sourceLabel(sourcePath: sourcePath, provider: .claude),
|
||||
sourcePath: sourcePath,
|
||||
timestamp: timestamp,
|
||||
sessionID: stringValue(object["sessionId"]),
|
||||
model: stringValue(message?["model"]) == "" ? "Claude" : stringValue(message?["model"]),
|
||||
inputTokens: intValue(usage["input_tokens"]),
|
||||
outputTokens: intValue(usage["output_tokens"]),
|
||||
cachedTokens: intValue(usage["cache_read_input_tokens"]) + intValue(usage["cache_creation_input_tokens"]),
|
||||
reasoningTokens: 0,
|
||||
toolTokens: 0
|
||||
)
|
||||
}
|
||||
|
||||
public static func parseCodexLine(_ line: String, sourcePath: String) -> UsageRecord? {
|
||||
guard let object = parseJSONObject(line),
|
||||
let payload = object["payload"] as? [String: Any],
|
||||
let info = payload["info"] as? [String: Any],
|
||||
let usage = info["last_token_usage"] as? [String: Any],
|
||||
let timestamp = parseDate(object["timestamp"])
|
||||
else { return nil }
|
||||
|
||||
return UsageRecord(
|
||||
provider: .codex,
|
||||
source: sourceLabel(sourcePath: sourcePath, provider: .codex),
|
||||
sourcePath: sourcePath,
|
||||
timestamp: timestamp,
|
||||
sessionID: stringValue(payload["id"] ?? payload["thread_id"]),
|
||||
model: stringValue(payload["model"]) == "" ? "Codex" : stringValue(payload["model"]),
|
||||
inputTokens: intValue(usage["input_tokens"]),
|
||||
outputTokens: intValue(usage["output_tokens"]),
|
||||
cachedTokens: intValue(usage["cached_input_tokens"]),
|
||||
reasoningTokens: intValue(usage["reasoning_output_tokens"]),
|
||||
toolTokens: 0
|
||||
)
|
||||
}
|
||||
|
||||
public static func parseGeminiLine(_ line: String, sourcePath: String) -> UsageRecord? {
|
||||
guard let object = parseJSONObject(line),
|
||||
let tokens = object["tokens"] as? [String: Any],
|
||||
let timestamp = parseDate(object["timestamp"])
|
||||
else { return nil }
|
||||
|
||||
return UsageRecord(
|
||||
provider: .gemini,
|
||||
source: sourceLabel(sourcePath: sourcePath, provider: .gemini),
|
||||
sourcePath: sourcePath,
|
||||
timestamp: timestamp,
|
||||
sessionID: stringValue(object["id"]),
|
||||
model: stringValue(object["model"]) == "" ? "Gemini" : stringValue(object["model"]),
|
||||
inputTokens: intValue(tokens["input"]),
|
||||
outputTokens: intValue(tokens["output"]),
|
||||
cachedTokens: intValue(tokens["cached"]),
|
||||
reasoningTokens: intValue(tokens["thoughts"]),
|
||||
toolTokens: intValue(tokens["tool"]),
|
||||
explicitTotal: intValue(tokens["total"]) == 0 ? nil : intValue(tokens["total"])
|
||||
)
|
||||
}
|
||||
|
||||
static func parseDate(_ value: Any?) -> Date? {
|
||||
guard let text = value as? String else { return nil }
|
||||
let isoFormatter = ISO8601DateFormatter()
|
||||
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
let fallbackISOFormatter = ISO8601DateFormatter()
|
||||
fallbackISOFormatter.formatOptions = [.withInternetDateTime]
|
||||
return isoFormatter.date(from: text) ?? fallbackISOFormatter.date(from: text)
|
||||
}
|
||||
|
||||
private static func parseJSONObject(_ line: String) -> [String: Any]? {
|
||||
guard let data = line.data(using: .utf8) else { return nil }
|
||||
return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
|
||||
}
|
||||
|
||||
private static func stringValue(_ value: Any?) -> String {
|
||||
value as? String ?? ""
|
||||
}
|
||||
|
||||
private static func intValue(_ value: Any?) -> Int {
|
||||
if let int = value as? Int { return int }
|
||||
if let double = value as? Double { return Int(double) }
|
||||
if let string = value as? String { return Int(string) ?? 0 }
|
||||
return 0
|
||||
}
|
||||
|
||||
private static func sourceLabel(sourcePath: String, provider: Provider) -> String {
|
||||
switch provider {
|
||||
case .claude: return "projects/*.jsonl"
|
||||
case .codex: return "sessions/*.jsonl"
|
||||
case .gemini: return "tmp/chats/*.jsonl"
|
||||
case .hermes: return "state.db/sessions"
|
||||
case .opencode: return "opencode.db/session"
|
||||
}
|
||||
}
|
||||
}
|
||||
525
NativeTokenLens/Sources/TokenLensCore/TokenUsageScanner.swift
Normal file
525
NativeTokenLens/Sources/TokenLensCore/TokenUsageScanner.swift
Normal file
@@ -0,0 +1,525 @@
|
||||
import Foundation
|
||||
import SQLite3
|
||||
|
||||
public actor TokenUsageScanner {
|
||||
public let homeDirectory: URL
|
||||
public let cacheURL: URL
|
||||
private let chunkSize = 256 * 1024
|
||||
private var lastSignature: FileSignature?
|
||||
private var lastSummary: UsageSummary?
|
||||
|
||||
public init(
|
||||
homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser,
|
||||
cacheURL: URL? = nil
|
||||
) {
|
||||
self.homeDirectory = homeDirectory
|
||||
self.cacheURL = cacheURL ?? homeDirectory
|
||||
.appending(path: "Library/Application Support/TokenLens")
|
||||
.appending(path: "usage-cache.sqlite")
|
||||
}
|
||||
|
||||
public func scan(now: Date = Date()) async -> UsageSummary {
|
||||
defer { MemoryPressure.relieve() }
|
||||
|
||||
if let summary = scanWithCache(now: now) {
|
||||
return summary
|
||||
}
|
||||
|
||||
var accumulator = UsageSummaryAccumulator(now: now)
|
||||
scanClaude(into: &accumulator)
|
||||
scanCodex(into: &accumulator)
|
||||
scanGemini(into: &accumulator)
|
||||
scanHermes(into: &accumulator)
|
||||
scanOpenCode(into: &accumulator)
|
||||
return accumulator.makeSummary()
|
||||
}
|
||||
|
||||
private func scanWithCache(now: Date) -> UsageSummary? {
|
||||
do {
|
||||
let snapshots = try logFileSnapshots()
|
||||
let signature = FileSignature(snapshots: snapshots)
|
||||
if signature == lastSignature,
|
||||
let lastSummary,
|
||||
isSameDisplayWindow(lastSummary.generatedAt, now) {
|
||||
let updated = lastSummary.updatingGeneratedAt(now)
|
||||
self.lastSummary = updated
|
||||
return updated
|
||||
}
|
||||
|
||||
let cache = try SQLiteUsageCache(url: cacheURL)
|
||||
let currentPaths = try refreshCache(cache, snapshots: snapshots)
|
||||
try cache.pruneMissingFiles(currentPaths: currentPaths)
|
||||
let summary = try cache.makeSummary(now: now)
|
||||
lastSignature = signature
|
||||
lastSummary = summary
|
||||
return summary
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshCache(_ cache: SQLiteUsageCache, snapshots: [LogFileSnapshot]) throws -> Set<String> {
|
||||
var currentPaths = Set<String>()
|
||||
for snapshot in snapshots {
|
||||
currentPaths.insert(snapshot.path)
|
||||
let state = try cache.fileState(path: snapshot.path)
|
||||
let isCurrent = try cache.isFileCurrent(
|
||||
path: snapshot.path,
|
||||
size: snapshot.size,
|
||||
modifiedAt: snapshot.modifiedAt
|
||||
)
|
||||
if isCurrent && (snapshot.scanner.supportsZeroRecordCache || (state?.recordCount ?? 0) > 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
if let state,
|
||||
snapshot.size > state.size,
|
||||
state.size == state.scannedOffset,
|
||||
state.scannedOffset > 0,
|
||||
snapshot.scanner.supportsIncrementalScan {
|
||||
try cache.appendFileRecords(
|
||||
path: snapshot.path,
|
||||
size: snapshot.size,
|
||||
modifiedAt: snapshot.modifiedAt,
|
||||
startingRecordCount: state.recordCount
|
||||
) { insert in
|
||||
try scanFile(
|
||||
snapshot.url,
|
||||
startingAt: UInt64(state.scannedOffset),
|
||||
scanner: snapshot.scanner,
|
||||
consume: insert
|
||||
).scannedOffset
|
||||
}
|
||||
} else {
|
||||
try cache.replaceFileRecords(
|
||||
path: snapshot.path,
|
||||
size: snapshot.size,
|
||||
modifiedAt: snapshot.modifiedAt
|
||||
) { insert in
|
||||
try scanFile(snapshot.url, scanner: snapshot.scanner, consume: insert).scannedOffset
|
||||
}
|
||||
}
|
||||
}
|
||||
return currentPaths
|
||||
}
|
||||
|
||||
private func scanClaude(into accumulator: inout UsageSummaryAccumulator) {
|
||||
scanJSONLFiles(
|
||||
root: homeDirectory.appending(path: ".claude/projects"),
|
||||
include: { $0.pathExtension == "jsonl" },
|
||||
parse: TokenUsageParser.parseClaudeLine,
|
||||
into: &accumulator
|
||||
)
|
||||
}
|
||||
|
||||
private func scanCodex(into accumulator: inout UsageSummaryAccumulator) {
|
||||
scanJSONLFiles(
|
||||
root: homeDirectory.appending(path: ".codex/sessions"),
|
||||
include: { $0.pathExtension == "jsonl" },
|
||||
parse: TokenUsageParser.parseCodexLine,
|
||||
into: &accumulator
|
||||
)
|
||||
}
|
||||
|
||||
private func scanGemini(into accumulator: inout UsageSummaryAccumulator) {
|
||||
scanJSONLFiles(
|
||||
root: homeDirectory.appending(path: ".gemini"),
|
||||
include: { url in
|
||||
url.pathExtension == "jsonl" && url.pathComponents.contains("chats")
|
||||
},
|
||||
parse: TokenUsageParser.parseGeminiLine,
|
||||
into: &accumulator
|
||||
)
|
||||
}
|
||||
|
||||
private func scanHermes(into accumulator: inout UsageSummaryAccumulator) {
|
||||
for file in files(root: homeDirectory.appending(path: ".hermes"), include: { $0.lastPathComponent == "state.db" }) {
|
||||
_ = try? scanHermesStateDB(file) { record in
|
||||
accumulator.add(record)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scanOpenCode(into accumulator: inout UsageSummaryAccumulator) {
|
||||
for file in files(root: homeDirectory.appending(path: ".local/share/opencode"), include: { $0.lastPathComponent == "opencode.db" }) {
|
||||
_ = try? scanOpenCodeDB(file) { record in
|
||||
accumulator.add(record)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scanJSONLFiles(
|
||||
root: URL,
|
||||
include: (URL) -> Bool,
|
||||
parse: (String, String) -> UsageRecord?,
|
||||
into accumulator: inout UsageSummaryAccumulator
|
||||
) {
|
||||
for file in files(root: root, include: include) {
|
||||
_ = try? scanJSONLFile(file, parse: parse) { record in
|
||||
accumulator.add(record)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scanFile(
|
||||
_ file: URL,
|
||||
startingAt offset: UInt64 = 0,
|
||||
scanner: SourceScanner,
|
||||
consume: (UsageRecord) throws -> Void
|
||||
) throws -> ScanProgress {
|
||||
switch scanner {
|
||||
case let .jsonl(parse):
|
||||
return try scanJSONLFile(file, startingAt: offset, parse: parse, consume: consume)
|
||||
case .hermesStateDB:
|
||||
return try scanHermesStateDB(file, consume: consume)
|
||||
case .openCodeDB:
|
||||
return try scanOpenCodeDB(file, consume: consume)
|
||||
}
|
||||
}
|
||||
|
||||
private func scanJSONLFile(
|
||||
_ file: URL,
|
||||
startingAt offset: UInt64 = 0,
|
||||
parse: (String, String) -> UsageRecord?,
|
||||
consume: (UsageRecord) throws -> Void
|
||||
) throws -> ScanProgress {
|
||||
guard let handle = try? FileHandle(forReadingFrom: file) else {
|
||||
return ScanProgress(scannedOffset: Int64(offset), recordCount: 0)
|
||||
}
|
||||
defer { try? handle.close() }
|
||||
try handle.seek(toOffset: offset)
|
||||
|
||||
var pendingLine = Data()
|
||||
var scannedOffset = Int64(offset)
|
||||
var recordCount = 0
|
||||
|
||||
while true {
|
||||
let chunk = (try? handle.read(upToCount: chunkSize)) ?? Data()
|
||||
if chunk.isEmpty { break }
|
||||
|
||||
var lineStart = chunk.startIndex
|
||||
for index in chunk.indices where chunk[index] == 0x0A {
|
||||
let lineData = chunk[lineStart..<index]
|
||||
if pendingLine.isEmpty {
|
||||
scannedOffset += Int64(lineData.count + 1)
|
||||
try autoreleasepool {
|
||||
if try appendRecord(from: lineData, sourcePath: file.path, parse: parse, consume: consume) {
|
||||
recordCount += 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pendingLine.append(contentsOf: lineData)
|
||||
scannedOffset += Int64(pendingLine.count + 1)
|
||||
try autoreleasepool {
|
||||
if try appendRecord(from: pendingLine, sourcePath: file.path, parse: parse, consume: consume) {
|
||||
recordCount += 1
|
||||
}
|
||||
}
|
||||
pendingLine.removeAll(keepingCapacity: true)
|
||||
}
|
||||
lineStart = chunk.index(after: index)
|
||||
}
|
||||
|
||||
if lineStart < chunk.endIndex {
|
||||
pendingLine.append(contentsOf: chunk[lineStart..<chunk.endIndex])
|
||||
}
|
||||
}
|
||||
|
||||
if !pendingLine.isEmpty {
|
||||
try autoreleasepool {
|
||||
if try appendRecord(from: pendingLine, sourcePath: file.path, parse: parse, consume: consume) {
|
||||
recordCount += 1
|
||||
scannedOffset += Int64(pendingLine.count)
|
||||
} else if isCompleteJSONLine(pendingLine) {
|
||||
scannedOffset += Int64(pendingLine.count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ScanProgress(scannedOffset: scannedOffset, recordCount: recordCount)
|
||||
}
|
||||
|
||||
private func appendRecord<LineData: Collection>(
|
||||
from lineData: LineData,
|
||||
sourcePath: String,
|
||||
parse: (String, String) -> UsageRecord?,
|
||||
consume: (UsageRecord) throws -> Void
|
||||
) throws -> Bool where LineData.Element == UInt8 {
|
||||
guard !lineData.isEmpty,
|
||||
let line = String(bytes: lineData, encoding: .utf8),
|
||||
let record = parse(line, sourcePath)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
try consume(record)
|
||||
return true
|
||||
}
|
||||
|
||||
private func isCompleteJSONLine(_ lineData: Data) -> Bool {
|
||||
guard !lineData.isEmpty else { return true }
|
||||
return (try? JSONSerialization.jsonObject(with: lineData)) != nil
|
||||
}
|
||||
|
||||
private func scanHermesStateDB(
|
||||
_ file: URL,
|
||||
consume: (UsageRecord) throws -> Void
|
||||
) throws -> ScanProgress {
|
||||
let sql = """
|
||||
SELECT id, started_at, COALESCE(model, ''),
|
||||
input_tokens, output_tokens, cache_read_tokens,
|
||||
cache_write_tokens, reasoning_tokens
|
||||
FROM sessions
|
||||
WHERE input_tokens + output_tokens + cache_read_tokens + cache_write_tokens + reasoning_tokens > 0
|
||||
"""
|
||||
return try scanSQLiteRows(file, sql: sql) { statement in
|
||||
UsageRecord(
|
||||
provider: .hermes,
|
||||
source: "state.db/sessions",
|
||||
sourcePath: file.path,
|
||||
timestamp: Date(timeIntervalSince1970: sqlite3_column_double(statement, 1)),
|
||||
sessionID: textColumn(statement, 0),
|
||||
model: textColumn(statement, 2).isEmpty ? "Hermes" : textColumn(statement, 2),
|
||||
inputTokens: intColumn(statement, 3),
|
||||
outputTokens: intColumn(statement, 4),
|
||||
cachedTokens: intColumn(statement, 5) + intColumn(statement, 6),
|
||||
reasoningTokens: intColumn(statement, 7),
|
||||
toolTokens: 0
|
||||
)
|
||||
} consume: {
|
||||
try consume($0)
|
||||
}
|
||||
}
|
||||
|
||||
private func scanOpenCodeDB(
|
||||
_ file: URL,
|
||||
consume: (UsageRecord) throws -> Void
|
||||
) throws -> ScanProgress {
|
||||
let sql = """
|
||||
SELECT id, COALESCE(NULLIF(time_updated, 0), time_created), COALESCE(model, ''),
|
||||
tokens_input, tokens_output, tokens_reasoning,
|
||||
tokens_cache_read, tokens_cache_write
|
||||
FROM session
|
||||
WHERE tokens_input + tokens_output + tokens_reasoning + tokens_cache_read + tokens_cache_write > 0
|
||||
"""
|
||||
return try scanSQLiteRows(file, sql: sql) { statement in
|
||||
UsageRecord(
|
||||
provider: .opencode,
|
||||
source: "opencode.db/session",
|
||||
sourcePath: file.path,
|
||||
timestamp: Date(timeIntervalSince1970: sqlite3_column_double(statement, 1) / 1000),
|
||||
sessionID: textColumn(statement, 0),
|
||||
model: openCodeModelName(textColumn(statement, 2)),
|
||||
inputTokens: intColumn(statement, 3),
|
||||
outputTokens: intColumn(statement, 4),
|
||||
cachedTokens: intColumn(statement, 6) + intColumn(statement, 7),
|
||||
reasoningTokens: intColumn(statement, 5),
|
||||
toolTokens: 0
|
||||
)
|
||||
} consume: {
|
||||
try consume($0)
|
||||
}
|
||||
}
|
||||
|
||||
private func scanSQLiteRows(
|
||||
_ file: URL,
|
||||
sql: String,
|
||||
makeRecord: (OpaquePointer?) -> UsageRecord,
|
||||
consume: (UsageRecord) throws -> Void
|
||||
) throws -> ScanProgress {
|
||||
var database: OpaquePointer?
|
||||
let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX
|
||||
guard sqlite3_open_v2(file.path, &database, flags, nil) == SQLITE_OK else {
|
||||
sqlite3_close(database)
|
||||
return ScanProgress(scannedOffset: fileSize(file), recordCount: 0)
|
||||
}
|
||||
defer { sqlite3_close(database) }
|
||||
sqlite3_busy_timeout(database, 1000)
|
||||
|
||||
var statement: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else {
|
||||
sqlite3_finalize(statement)
|
||||
return ScanProgress(scannedOffset: fileSize(file), recordCount: 0)
|
||||
}
|
||||
defer { sqlite3_finalize(statement) }
|
||||
|
||||
var recordCount = 0
|
||||
while sqlite3_step(statement) == SQLITE_ROW {
|
||||
try consume(makeRecord(statement))
|
||||
recordCount += 1
|
||||
}
|
||||
return ScanProgress(scannedOffset: fileSize(file), recordCount: recordCount)
|
||||
}
|
||||
|
||||
private func fileSize(_ file: URL) -> Int64 {
|
||||
((try? file.resourceValues(forKeys: [.fileSizeKey]).fileSize).flatMap { Int64($0) }) ?? 0
|
||||
}
|
||||
|
||||
private func textColumn(_ statement: OpaquePointer?, _ index: Int32) -> String {
|
||||
guard let text = sqlite3_column_text(statement, index) else { return "" }
|
||||
return String(cString: UnsafeRawPointer(text).assumingMemoryBound(to: CChar.self))
|
||||
}
|
||||
|
||||
private func intColumn(_ statement: OpaquePointer?, _ index: Int32) -> Int {
|
||||
Int(sqlite3_column_int64(statement, index))
|
||||
}
|
||||
|
||||
private func openCodeModelName(_ rawModel: String) -> String {
|
||||
guard !rawModel.isEmpty,
|
||||
let data = rawModel.data(using: .utf8),
|
||||
let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
|
||||
else {
|
||||
return rawModel.isEmpty ? "OpenCode" : rawModel
|
||||
}
|
||||
let provider = object["providerID"] as? String
|
||||
let model = object["id"] as? String
|
||||
var parts: [String] = []
|
||||
if let provider, !provider.isEmpty {
|
||||
parts.append(provider)
|
||||
}
|
||||
if let model, !model.isEmpty {
|
||||
parts.append(model)
|
||||
}
|
||||
return parts.isEmpty ? "OpenCode" : parts.joined(separator: "/")
|
||||
}
|
||||
|
||||
private func files(root: URL, include: (URL) -> Bool) -> [URL] {
|
||||
guard let enumerator = FileManager.default.enumerator(
|
||||
at: root,
|
||||
includingPropertiesForKeys: [.isRegularFileKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
return []
|
||||
}
|
||||
|
||||
var result: [URL] = []
|
||||
for case let file as URL in enumerator where include(file) {
|
||||
result.append(file)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private var logSources: [LogSource] {
|
||||
[
|
||||
LogSource(
|
||||
root: homeDirectory.appending(path: ".claude/projects"),
|
||||
include: { $0.pathExtension == "jsonl" },
|
||||
scanner: .jsonl(TokenUsageParser.parseClaudeLine)
|
||||
),
|
||||
LogSource(
|
||||
root: homeDirectory.appending(path: ".codex/sessions"),
|
||||
include: { $0.pathExtension == "jsonl" },
|
||||
scanner: .jsonl(TokenUsageParser.parseCodexLine)
|
||||
),
|
||||
LogSource(
|
||||
root: homeDirectory.appending(path: ".gemini"),
|
||||
include: { url in
|
||||
url.pathExtension == "jsonl" && url.pathComponents.contains("chats")
|
||||
},
|
||||
scanner: .jsonl(TokenUsageParser.parseGeminiLine)
|
||||
),
|
||||
LogSource(
|
||||
root: homeDirectory.appending(path: ".hermes"),
|
||||
include: {
|
||||
$0.lastPathComponent == "state.db" &&
|
||||
$0.deletingLastPathComponent().lastPathComponent == ".hermes"
|
||||
},
|
||||
scanner: .hermesStateDB
|
||||
),
|
||||
LogSource(
|
||||
root: homeDirectory.appending(path: ".local/share/opencode"),
|
||||
include: {
|
||||
$0.lastPathComponent == "opencode.db" &&
|
||||
$0.deletingLastPathComponent().lastPathComponent == "opencode"
|
||||
},
|
||||
scanner: .openCodeDB
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
private func logFileSnapshots() throws -> [LogFileSnapshot] {
|
||||
var snapshots: [LogFileSnapshot] = []
|
||||
for source in logSources {
|
||||
for file in files(root: source.root, include: source.include) {
|
||||
let metadata = try fileMetadata(file)
|
||||
snapshots.append(LogFileSnapshot(
|
||||
url: file,
|
||||
path: file.path,
|
||||
size: metadata.size,
|
||||
modifiedAt: metadata.modifiedAt,
|
||||
scanner: source.scanner
|
||||
))
|
||||
}
|
||||
}
|
||||
return snapshots.sorted { $0.path < $1.path }
|
||||
}
|
||||
|
||||
private func fileMetadata(_ file: URL) throws -> FileMetadata {
|
||||
let values = try file.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey])
|
||||
return FileMetadata(
|
||||
size: Int64(values.fileSize ?? 0),
|
||||
modifiedAt: values.contentModificationDate?.timeIntervalSince1970 ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
private func isSameDisplayWindow(_ lhs: Date, _ rhs: Date) -> Bool {
|
||||
var calendar = Calendar.current
|
||||
calendar.firstWeekday = 2
|
||||
return calendar.isDate(lhs, equalTo: rhs, toGranularity: .hour)
|
||||
}
|
||||
}
|
||||
|
||||
private struct LogSource {
|
||||
let root: URL
|
||||
let include: (URL) -> Bool
|
||||
let scanner: SourceScanner
|
||||
}
|
||||
|
||||
private enum SourceScanner {
|
||||
case jsonl((String, String) -> UsageRecord?)
|
||||
case hermesStateDB
|
||||
case openCodeDB
|
||||
|
||||
var supportsIncrementalScan: Bool {
|
||||
if case .jsonl = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var supportsZeroRecordCache: Bool {
|
||||
if case .jsonl = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private struct ScanProgress {
|
||||
let scannedOffset: Int64
|
||||
let recordCount: Int
|
||||
}
|
||||
|
||||
private struct FileMetadata {
|
||||
let size: Int64
|
||||
let modifiedAt: TimeInterval
|
||||
}
|
||||
|
||||
private struct LogFileSnapshot {
|
||||
let url: URL
|
||||
let path: String
|
||||
let size: Int64
|
||||
let modifiedAt: TimeInterval
|
||||
let scanner: SourceScanner
|
||||
}
|
||||
|
||||
private struct FileSignature: Equatable {
|
||||
let entries: [Entry]
|
||||
|
||||
init(snapshots: [LogFileSnapshot]) {
|
||||
entries = snapshots.map {
|
||||
Entry(path: $0.path, size: $0.size, modifiedAt: $0.modifiedAt)
|
||||
}
|
||||
}
|
||||
|
||||
struct Entry: Equatable {
|
||||
let path: String
|
||||
let size: Int64
|
||||
let modifiedAt: TimeInterval
|
||||
}
|
||||
}
|
||||
330
NativeTokenLens/Sources/TokenLensCore/UsageSummary.swift
Normal file
330
NativeTokenLens/Sources/TokenLensCore/UsageSummary.swift
Normal file
@@ -0,0 +1,330 @@
|
||||
import Foundation
|
||||
|
||||
public struct UsageSummaryAccumulator {
|
||||
private let now: Date
|
||||
private var calendar: Calendar
|
||||
private let today: Date
|
||||
private let month: DateComponents
|
||||
private let trendWindows: [TrendMode: TrendWindow]
|
||||
|
||||
private var providerTotals: [Provider: TokenBucket]
|
||||
private var todayProviderTotals: [Provider: TokenBucket]
|
||||
private var dailyBuckets: [Date: [Provider: Int]] = [:]
|
||||
private var dailyDetails: [Date: DailyUsageDetail] = [:]
|
||||
private var trendBuckets: [TrendMode: [Date: [Provider: Int]]] = [:]
|
||||
private var todayTokens = 0
|
||||
private var monthTokens = 0
|
||||
private var totalTokens = 0
|
||||
private var cachedTokens = 0
|
||||
private var sessions = Set<String>()
|
||||
|
||||
public init(
|
||||
now: Date = Date(),
|
||||
calendar baseCalendar: Calendar = Calendar.current
|
||||
) {
|
||||
self.now = now
|
||||
var calendar = baseCalendar
|
||||
calendar.firstWeekday = 2
|
||||
self.calendar = calendar
|
||||
today = calendar.startOfDay(for: now)
|
||||
month = calendar.dateComponents([.year, .month], from: now)
|
||||
providerTotals = Dictionary(uniqueKeysWithValues: Provider.allCases.map {
|
||||
($0, TokenBucket(provider: $0))
|
||||
})
|
||||
todayProviderTotals = Dictionary(uniqueKeysWithValues: Provider.allCases.map {
|
||||
($0, TokenBucket(provider: $0))
|
||||
})
|
||||
trendWindows = Dictionary(uniqueKeysWithValues: TrendMode.allCases.map { mode in
|
||||
let reference = mode.periodStart(for: now, calendar: calendar)
|
||||
return (mode, TrendWindow(
|
||||
reference: reference,
|
||||
start: mode.startDate(reference: reference, calendar: calendar),
|
||||
component: mode.calendarComponent
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
public mutating func add(_ record: UsageRecord) {
|
||||
add(
|
||||
provider: record.provider,
|
||||
sourcePath: record.sourcePath,
|
||||
timestamp: record.timestamp,
|
||||
sessionID: record.sessionID,
|
||||
inputTokens: record.inputTokens,
|
||||
outputTokens: record.outputTokens,
|
||||
cachedTokens: record.cachedTokens,
|
||||
reasoningTokens: record.reasoningTokens,
|
||||
toolTokens: record.toolTokens,
|
||||
totalTokens: record.totalTokens
|
||||
)
|
||||
}
|
||||
|
||||
public mutating func add(
|
||||
provider: Provider,
|
||||
sourcePath: String,
|
||||
timestamp: Date,
|
||||
sessionID: String,
|
||||
inputTokens: Int,
|
||||
outputTokens: Int,
|
||||
cachedTokens: Int,
|
||||
reasoningTokens: Int,
|
||||
toolTokens: Int,
|
||||
totalTokens: Int
|
||||
) {
|
||||
self.totalTokens += totalTokens
|
||||
self.cachedTokens += cachedTokens
|
||||
sessions.insert(sessionID.isEmpty ? sourcePath : sessionID)
|
||||
providerTotals[provider, default: TokenBucket(provider: provider)].add(
|
||||
inputTokens: inputTokens,
|
||||
outputTokens: outputTokens,
|
||||
cachedTokens: cachedTokens,
|
||||
reasoningTokens: reasoningTokens,
|
||||
toolTokens: toolTokens,
|
||||
totalTokens: totalTokens
|
||||
)
|
||||
|
||||
let recordDay = calendar.startOfDay(for: timestamp)
|
||||
dailyBuckets[recordDay, default: Provider.zeroTokenMap][provider, default: 0] += totalTokens
|
||||
if dailyDetails[recordDay] == nil {
|
||||
dailyDetails[recordDay] = DailyUsageDetail(date: recordDay)
|
||||
}
|
||||
dailyDetails[recordDay]?.add(
|
||||
provider: provider,
|
||||
inputTokens: inputTokens,
|
||||
outputTokens: outputTokens,
|
||||
cachedTokens: cachedTokens,
|
||||
totalTokens: totalTokens
|
||||
)
|
||||
|
||||
if recordDay == today {
|
||||
todayTokens += totalTokens
|
||||
todayProviderTotals[provider, default: TokenBucket(provider: provider)].add(
|
||||
inputTokens: inputTokens,
|
||||
outputTokens: outputTokens,
|
||||
cachedTokens: cachedTokens,
|
||||
reasoningTokens: reasoningTokens,
|
||||
toolTokens: toolTokens,
|
||||
totalTokens: totalTokens
|
||||
)
|
||||
}
|
||||
|
||||
let recordMonth = calendar.dateComponents([.year, .month], from: timestamp)
|
||||
if recordMonth.year == month.year && recordMonth.month == month.month {
|
||||
monthTokens += totalTokens
|
||||
}
|
||||
|
||||
for mode in TrendMode.allCases {
|
||||
guard let window = trendWindows[mode] else { continue }
|
||||
let period = mode.periodStart(for: timestamp, calendar: calendar)
|
||||
guard period >= window.start && period <= window.reference else { continue }
|
||||
trendBuckets[mode, default: [:]][period, default: Provider.zeroTokenMap][provider, default: 0] += totalTokens
|
||||
}
|
||||
}
|
||||
|
||||
public func makeSummary() -> UsageSummary {
|
||||
let dailyTrend = dailyBuckets
|
||||
.sorted { $0.key < $1.key }
|
||||
.suffix(30)
|
||||
.map { DailyPoint(date: $0.key, values: $0.value) }
|
||||
|
||||
let sortedDailyDetails = dailyDetails.values.sorted { $0.date > $1.date }
|
||||
let trendPointsByMode = Dictionary(uniqueKeysWithValues: TrendMode.allCases.map { mode in
|
||||
(mode, makeTrendPoints(mode: mode))
|
||||
})
|
||||
|
||||
let toolRows = Provider.allCases.compactMap { provider -> ToolRow? in
|
||||
guard let bucket = providerTotals[provider], bucket.totalTokens > 0 else { return nil }
|
||||
let trendTokens = dailyTrend.map { $0.values[provider, default: 0] }
|
||||
return ToolRow(
|
||||
provider: provider,
|
||||
inputTokens: bucket.inputTokens,
|
||||
outputTokens: bucket.outputTokens,
|
||||
cachedTokens: bucket.cachedTokens,
|
||||
totalTokens: bucket.totalTokens,
|
||||
trendTokens: trendTokens
|
||||
)
|
||||
}
|
||||
.sorted { $0.totalTokens > $1.totalTokens }
|
||||
|
||||
let cacheHitRate = totalTokens > 0 ? Int((Double(cachedTokens) / Double(totalTokens) * 100).rounded()) : 0
|
||||
let cards = SummaryCards(
|
||||
todayTokens: todayTokens,
|
||||
monthTokens: monthTokens,
|
||||
totalTokens: totalTokens,
|
||||
cacheHitRate: cacheHitRate,
|
||||
sessionCount: sessions.count
|
||||
)
|
||||
|
||||
return UsageSummary(
|
||||
generatedAt: now,
|
||||
records: [],
|
||||
cards: cards,
|
||||
providerTotals: providerTotals,
|
||||
todayProviderTotals: todayProviderTotals,
|
||||
dailyTrend: Array(dailyTrend),
|
||||
trendPointsByMode: trendPointsByMode,
|
||||
dailyDetails: sortedDailyDetails,
|
||||
toolRows: toolRows,
|
||||
insights: UsageSummary.makeInsights(
|
||||
providerTotals: providerTotals,
|
||||
todayTokens: todayTokens,
|
||||
cacheHitRate: cacheHitRate
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func makeTrendPoints(mode: TrendMode) -> [DailyPoint] {
|
||||
guard let window = trendWindows[mode] else { return [] }
|
||||
let buckets = trendBuckets[mode, default: [:]]
|
||||
var points: [DailyPoint] = []
|
||||
var cursor = window.start
|
||||
while cursor <= window.reference {
|
||||
points.append(DailyPoint(date: cursor, values: buckets[cursor, default: Provider.zeroTokenMap]))
|
||||
guard let next = calendar.date(byAdding: window.component, value: 1, to: cursor) else { break }
|
||||
cursor = next
|
||||
}
|
||||
return points
|
||||
}
|
||||
}
|
||||
|
||||
private struct TrendWindow {
|
||||
let reference: Date
|
||||
let start: Date
|
||||
let component: Calendar.Component
|
||||
}
|
||||
|
||||
public extension UsageSummary {
|
||||
static func make(
|
||||
records: [UsageRecord],
|
||||
now: Date = Date(),
|
||||
calendar baseCalendar: Calendar = Calendar.current
|
||||
) -> UsageSummary {
|
||||
var accumulator = UsageSummaryAccumulator(now: now, calendar: baseCalendar)
|
||||
for record in records {
|
||||
accumulator.add(record)
|
||||
}
|
||||
return accumulator.makeSummary()
|
||||
}
|
||||
|
||||
static func makeInsights(
|
||||
providerTotals: [Provider: TokenBucket],
|
||||
todayTokens: Int,
|
||||
cacheHitRate: Int
|
||||
) -> [Insight] {
|
||||
let top = providerTotals.values
|
||||
.filter { $0.totalTokens > 0 }
|
||||
.sorted { $0.totalTokens > $1.totalTokens }
|
||||
.first
|
||||
|
||||
var insights: [Insight] = []
|
||||
if let top, let provider = top.provider {
|
||||
insights.append(Insight(
|
||||
tone: .warning,
|
||||
title: "\(provider.rawValue) 是当前主要用量来源",
|
||||
message: "已从本地日志统计 \(TokenFormatter.format(top.totalTokens)) Tokens。"
|
||||
))
|
||||
}
|
||||
insights.append(Insight(
|
||||
tone: .positive,
|
||||
title: "检测到缓存 Tokens",
|
||||
message: "当前 \(cacheHitRate)% 的统计用量来自缓存上下文。"
|
||||
))
|
||||
insights.append(Insight(
|
||||
tone: .accent,
|
||||
title: "今日用量快照已更新",
|
||||
message: "当前本地日期已统计 \(TokenFormatter.format(todayTokens)) Tokens。"
|
||||
))
|
||||
return insights
|
||||
}
|
||||
|
||||
func trendPoints(
|
||||
mode: TrendMode,
|
||||
calendar baseCalendar: Calendar = Calendar.current
|
||||
) -> [DailyPoint] {
|
||||
if let points = trendPointsByMode[mode] {
|
||||
return points
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
func dailyUsageDetails(calendar baseCalendar: Calendar = Calendar.current) -> [DailyUsageDetail] {
|
||||
dailyDetails
|
||||
}
|
||||
|
||||
func updatingGeneratedAt(_ date: Date) -> UsageSummary {
|
||||
UsageSummary(
|
||||
generatedAt: date,
|
||||
records: records,
|
||||
cards: cards,
|
||||
providerTotals: providerTotals,
|
||||
todayProviderTotals: todayProviderTotals,
|
||||
dailyTrend: dailyTrend,
|
||||
trendPointsByMode: trendPointsByMode,
|
||||
dailyDetails: dailyDetails,
|
||||
toolRows: toolRows,
|
||||
insights: insights
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension Provider {
|
||||
static var zeroTokenMap: [Provider: Int] {
|
||||
Dictionary(uniqueKeysWithValues: Provider.allCases.map { ($0, 0) })
|
||||
}
|
||||
}
|
||||
|
||||
extension TrendMode {
|
||||
var calendarComponent: Calendar.Component {
|
||||
switch self {
|
||||
case .daily:
|
||||
return .hour
|
||||
case .weekly:
|
||||
return .day
|
||||
case .monthly:
|
||||
return .day
|
||||
case .yearly:
|
||||
return .month
|
||||
}
|
||||
}
|
||||
|
||||
var periodColumn: String {
|
||||
switch self {
|
||||
case .daily:
|
||||
return "hour_start"
|
||||
case .weekly, .monthly:
|
||||
return "day_start"
|
||||
case .yearly:
|
||||
return "month_start"
|
||||
}
|
||||
}
|
||||
|
||||
func periodStart(for date: Date, calendar: Calendar) -> Date {
|
||||
switch self {
|
||||
case .daily:
|
||||
let components = calendar.dateComponents([.year, .month, .day, .hour], from: date)
|
||||
return calendar.date(from: components) ?? calendar.startOfDay(for: date)
|
||||
case .weekly:
|
||||
return calendar.startOfDay(for: date)
|
||||
case .monthly:
|
||||
return calendar.startOfDay(for: date)
|
||||
case .yearly:
|
||||
let components = calendar.dateComponents([.year, .month], from: date)
|
||||
return calendar.date(from: components) ?? calendar.startOfDay(for: date)
|
||||
}
|
||||
}
|
||||
|
||||
func startDate(reference: Date, calendar: Calendar) -> Date {
|
||||
switch self {
|
||||
case .daily:
|
||||
return calendar.startOfDay(for: reference)
|
||||
case .weekly:
|
||||
return calendar.dateInterval(of: .weekOfYear, for: reference)?.start ?? calendar.startOfDay(for: reference)
|
||||
case .monthly:
|
||||
let components = calendar.dateComponents([.year, .month], from: reference)
|
||||
return calendar.date(from: components) ?? calendar.startOfDay(for: reference)
|
||||
case .yearly:
|
||||
let components = calendar.dateComponents([.year], from: reference)
|
||||
return calendar.date(from: components) ?? calendar.startOfDay(for: reference)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user