Files
oai-swift/oAI/Views/Screens/StatsView.swift
T
rune c3abc5a748 Update remaining oai.pm references to confab.no
Covers the per-file license-header comment (~80 Swift files) plus
the contact/website links in README.md, PRIVACY.md, and SECURITY.md.
2026-08-03 08:44:35 +02:00

277 lines
9.7 KiB
Swift

//
// StatsView.swift
// Confab
//
// Session statistics screen
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of Confab.
//
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling Confab or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://confab.no>.
import SwiftUI
private enum StatsTab: String, CaseIterable {
case session = "Session"
case allTime = "All-Time"
}
struct StatsView: View {
let stats: SessionStats
let model: ModelInfo?
let provider: Settings.Provider
@Environment(\.dismiss) var dismiss
@State private var selectedTab: StatsTab = .session
@State private var overallStats = UsageStats()
@State private var modelStats: [ModelUsageStat] = []
@State private var conversationStats: [ConversationUsageStat] = []
var body: some View {
NavigationStack {
VStack(spacing: 0) {
Picker("", selection: $selectedTab) {
Text("Session").tag(StatsTab.session)
Text("All-Time").tag(StatsTab.allTime)
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 4)
Group {
switch selectedTab {
case .session:
sessionList
case .allTime:
allTimeList
}
}
}
.navigationTitle("Statistics")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") {
dismiss()
}
}
}
.frame(minWidth: 500, idealWidth: 550, minHeight: 450, idealHeight: 500)
}
.task {
loadAllTimeStats()
}
}
private var sessionList: some View {
List {
Section("Session Info") {
StatRow(label: "Provider", value: provider.displayName)
StatRow(label: "Model", value: model?.name ?? "None selected")
StatRow(label: "Messages", value: "\(stats.messageCount)")
}
Section("Token Usage") {
StatRow(label: "Input Tokens", value: stats.totalInputTokens.formatted())
StatRow(label: "Output Tokens", value: stats.totalOutputTokens.formatted())
StatRow(label: "Total Tokens", value: stats.totalTokens.formatted())
if stats.totalTokens > 0 {
HStack {
Text("Token Distribution")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
GeometryReader { geo in
HStack(spacing: 0) {
Rectangle()
.fill(Color.blue)
.frame(width: geo.size.width * CGFloat(stats.totalInputTokens) / CGFloat(stats.totalTokens))
Rectangle()
.fill(Color.green)
.frame(width: geo.size.width * CGFloat(stats.totalOutputTokens) / CGFloat(stats.totalTokens))
}
}
.frame(height: 20)
.cornerRadius(4)
}
}
}
Section("Costs") {
StatRow(label: "Total Cost", value: stats.totalCostDisplay)
if stats.messageCount > 0 {
StatRow(label: "Avg per Message", value: stats.averageCostDisplay)
}
}
if let model = model {
Section("Model Details") {
StatRow(label: "Context Length", value: model.contextLengthDisplay)
StatRow(label: "Prompt Price", value: model.promptPriceDisplay + "/1M tokens")
StatRow(label: "Completion Price", value: model.completionPriceDisplay + "/1M tokens")
HStack {
Text("Capabilities")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
HStack(spacing: 8) {
if model.capabilities.vision {
CapabilityBadge(icon: "👁️", label: "Vision")
}
if model.capabilities.tools {
CapabilityBadge(icon: "🔧", label: "Tools")
}
if model.capabilities.online {
CapabilityBadge(icon: "🌐", label: "Online")
}
}
}
}
}
}
#if os(iOS)
.listStyle(.insetGrouped)
#else
.listStyle(.sidebar)
#endif
}
private var allTimeList: some View {
List {
Section("Totals") {
StatRow(label: "Total Messages", value: "\(overallStats.totalMessages)")
StatRow(label: "Total Tokens", value: overallStats.totalTokensDisplay)
StatRow(label: "Total Cost", value: overallStats.totalCostDisplay)
if let first = overallStats.firstMessageDate {
StatRow(label: "Since", value: first.formatted(date: .abbreviated, time: .omitted))
}
}
if !modelStats.isEmpty {
Section("By Model") {
ForEach(modelStats) { stat in
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(stat.modelId)
.font(.body)
.lineLimit(1)
Spacer()
Text(stat.totalCostDisplay)
.font(.body.monospacedDigit())
.foregroundColor(.secondary)
}
HStack {
Text("^[\(stat.messageCount) message](inflect: true) · \(stat.totalTokensDisplay) tokens")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 2)
}
}
}
if !conversationStats.isEmpty {
Section("Top Conversations") {
ForEach(conversationStats) { stat in
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(stat.name)
.font(.body)
.lineLimit(1)
Spacer()
Text(stat.totalCostDisplay)
.font(.body.monospacedDigit())
.foregroundColor(.secondary)
}
Text("^[\(stat.messageCount) message](inflect: true) · \(stat.totalTokensDisplay) tokens")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.vertical, 2)
}
}
}
if overallStats.totalMessages == 0 {
Section {
Text("No usage data yet")
.foregroundColor(.secondary)
}
}
}
#if os(iOS)
.listStyle(.insetGrouped)
#else
.listStyle(.sidebar)
#endif
}
private func loadAllTimeStats() {
overallStats = (try? DatabaseService.shared.getOverallUsageStats()) ?? UsageStats()
modelStats = (try? DatabaseService.shared.getUsageByModel()) ?? []
conversationStats = (try? DatabaseService.shared.getUsageByConversation()) ?? []
}
}
struct StatRow: View {
let label: LocalizedStringKey
let value: String
var body: some View {
HStack {
Text(label)
.font(.body)
Spacer()
Text(value)
.font(.body.monospacedDigit())
.foregroundColor(.secondary)
}
}
}
struct CapabilityBadge: View {
let icon: String
let label: LocalizedStringKey
var body: some View {
HStack(spacing: 2) {
Text(icon)
Text(label)
}
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(Color.blue.opacity(0.1))
.cornerRadius(4)
}
}
#Preview {
StatsView(
stats: SessionStats(
totalInputTokens: 1250,
totalOutputTokens: 3420,
totalCost: 0.0152,
messageCount: 12
),
model: ModelInfo.mockModels.first,
provider: .openrouter
)
}