Files
oai-swift/oAI/Views/Screens/ModelInfoView.swift
T
runeandClaude Sonnet 5 0af644456e Update commercial licensing contact URL to oai.pm
Consolidates the mac.oai.pm subdomain references (introduced in the
PolyForm Noncommercial relicense) to the root oai.pm domain, across
the LICENSE file, README, and all Swift source file headers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 11:34:04 +02:00

332 lines
13 KiB
Swift

//
// ModelInfoView.swift
// oAI
//
// Rich model information modal
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI 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 oAI 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://oai.pm>.
import SwiftUI
struct ModelInfoView: View {
let model: ModelInfo
@Environment(\.dismiss) var dismiss
@Bindable private var settings = SettingsService.shared
@State private var isDescriptionExpanded = false
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Model Info")
.font(.system(size: 18, weight: .bold))
Spacer()
let isFav = settings.favoriteModelIds.contains(model.id)
Button(action: { settings.toggleFavoriteModel(model.id) }) {
Image(systemName: isFav ? "star.fill" : "star")
.font(.system(size: 18))
.foregroundColor(isFav ? .yellow : .secondary)
}
.buttonStyle(.plain)
.help(isFav ? "Remove from favorites" : "Add to favorites")
.padding(.trailing, 8)
Button { dismiss() } label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.keyboardShortcut(.escape, modifiers: [])
}
.padding(.horizontal, 24)
.padding(.top, 20)
.padding(.bottom, 12)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 20) {
// Overview
sectionHeader("Overview")
infoRow("Name", model.name)
infoRow("ID", model.id)
if let provider = model.topProvider {
infoRow("Provider", provider)
}
if let desc = model.description {
VStack(alignment: .leading, spacing: 6) {
Text("Description")
.font(.subheadline.weight(.medium))
.foregroundColor(.secondary)
Text(desc)
.font(.body)
.foregroundColor(.primary)
.lineLimit(isDescriptionExpanded ? nil : 4)
.textSelection(.enabled)
if desc.count > 250 {
Button(isDescriptionExpanded ? "Less" : "More…") {
withAnimation(.easeInOut(duration: 0.2)) {
isDescriptionExpanded.toggle()
}
}
.font(.callout)
.foregroundStyle(.blue)
.buttonStyle(.plain)
}
}
.padding(.leading, 4)
}
Divider()
// Pricing
sectionHeader("Pricing")
infoRow("Input", model.promptPriceDisplay + " / 1M tokens")
infoRow("Output", model.completionPriceDisplay + " / 1M tokens")
if model.pricing.prompt > 0 {
VStack(alignment: .leading, spacing: 6) {
Text("Cost Examples")
.font(.caption)
.foregroundColor(.secondary)
HStack(spacing: 16) {
costExample(label: "1K tokens", inputTokens: 1_000)
costExample(label: "10K tokens", inputTokens: 10_000)
costExample(label: "100K tokens", inputTokens: 100_000)
}
}
.padding(.leading, 4)
}
Divider()
// Context Window
sectionHeader("Context Window")
infoRow("Max Tokens", model.contextLength.formatted())
if model.contextLength > 0 {
VStack(alignment: .leading, spacing: 4) {
let maxContext = 2_000_000.0
let fraction = min(Double(model.contextLength) / maxContext, 1.0)
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.gray.opacity(0.2))
.frame(height: 16)
GeometryReader { geo in
RoundedRectangle(cornerRadius: 4)
.fill(Color.blue)
.frame(width: geo.size.width * fraction, height: 16)
}
.frame(height: 16)
}
Text(model.contextLengthDisplay + " tokens")
.font(.caption2)
.foregroundColor(.secondary)
}
.padding(.leading, 4)
}
Divider()
// Capabilities
sectionHeader("Capabilities")
HStack(spacing: 12) {
capabilityBadge(icon: "eye.fill", label: "Vision", active: model.capabilities.vision)
capabilityBadge(icon: "wrench.fill", label: "Tools", active: model.capabilities.tools)
capabilityBadge(icon: "globe", label: "Online", active: model.capabilities.online)
capabilityBadge(icon: "photo.fill", label: "Image Gen", active: model.capabilities.imageGeneration)
capabilityBadge(icon: "brain", label: "Thinking", active: model.capabilities.thinking)
}
// Categories (if any)
if !model.categories.isEmpty {
Divider()
sectionHeader("Categories")
FlowLayout(spacing: 8) {
ForEach(model.categories, id: \.rawValue) { cat in
HStack(spacing: 5) {
Image(systemName: cat.systemImage)
.font(.caption2)
Text(LocalizedStringKey(cat.rawValue))
.font(.caption)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(cat.color.opacity(0.12))
.foregroundColor(cat.color)
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(cat.color.opacity(0.35), lineWidth: 1)
)
}
}
.padding(.leading, 4)
}
// Architecture (if available)
if let arch = model.architecture {
Divider()
sectionHeader("Architecture")
if let modality = arch.modality {
infoRow("Modality", modality)
}
if let tokenizer = arch.tokenizer {
infoRow("Tokenizer", tokenizer)
}
if let instructType = arch.instructType {
infoRow("Instruct Type", instructType)
}
}
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
Divider()
// Bottom bar
HStack {
Spacer()
Button("Done") { dismiss() }
.keyboardShortcut(.return, modifiers: [])
.buttonStyle(.borderedProminent)
.controlSize(.regular)
Spacer()
}
.padding(.horizontal, 24)
.padding(.vertical, 12)
}
.frame(minWidth: 550, idealWidth: 650, minHeight: 550, idealHeight: 750)
}
// MARK: - Layout Helpers
private func sectionHeader(_ title: LocalizedStringKey) -> some View {
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
}
private func infoRow(_ label: LocalizedStringKey, _ value: String) -> some View {
HStack {
Text(label)
.font(.body)
Spacer()
Text(value)
.font(.body)
.foregroundColor(.secondary)
.textSelection(.enabled)
}
.padding(.leading, 4)
}
@ViewBuilder
private func costExample(label: LocalizedStringKey, inputTokens: Int) -> some View {
let cost = (Double(inputTokens) * model.pricing.prompt / 1_000_000) +
(Double(inputTokens) * model.pricing.completion / 1_000_000)
VStack(spacing: 2) {
Text(label)
.font(.caption2)
.foregroundColor(.secondary)
Text(String(format: "$%.4f", cost))
.font(.caption.monospacedDigit())
.foregroundColor(.primary)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.gray.opacity(0.1))
.cornerRadius(4)
}
@ViewBuilder
private func capabilityBadge(icon: String, label: LocalizedStringKey, active: Bool) -> some View {
VStack(spacing: 4) {
Image(systemName: icon)
.font(.title3)
.foregroundColor(active ? .blue : .gray.opacity(0.4))
Text(label)
.font(.caption2)
.foregroundColor(active ? .primary : .secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(active ? Color.blue.opacity(0.1) : Color.gray.opacity(0.05))
.cornerRadius(8)
}
}
// MARK: - Flow Layout
struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
let rows = rows(for: subviews, width: proposal.width ?? .infinity)
let height = rows.map { row in
row.map { $0.sizeThatFits(.unspecified).height }.max() ?? 0
}.reduce(0, +) + CGFloat(max(0, rows.count - 1)) * spacing
return CGSize(width: proposal.width ?? 0, height: height)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
let rows = rows(for: subviews, width: bounds.width)
var y = bounds.minY
for row in rows {
var x = bounds.minX
let rowH = row.map { $0.sizeThatFits(.unspecified).height }.max() ?? 0
for sub in row {
let size = sub.sizeThatFits(.unspecified)
sub.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
x += size.width + spacing
}
y += rowH + spacing
}
}
private func rows(for subviews: Subviews, width: CGFloat) -> [[LayoutSubview]] {
var rows: [[LayoutSubview]] = [[]]
var rowWidth: CGFloat = 0
for sub in subviews {
let w = sub.sizeThatFits(.unspecified).width
if rowWidth + w > width, !rows.last!.isEmpty {
rows.append([])
rowWidth = 0
}
rows[rows.count - 1].append(sub)
rowWidth += w + spacing
}
return rows
}
}
#Preview {
ModelInfoView(model: ModelInfo(
id: "anthropic/claude-sonnet-4",
name: "Claude Sonnet 4",
description: "Balanced intelligence and speed. This is a longer description to test how the modal handles multi-line text that wraps across several lines in the description field.",
contextLength: 200_000,
pricing: .init(prompt: 3.0, completion: 15.0),
capabilities: .init(vision: true, tools: true, online: false),
architecture: .init(tokenizer: "claude", instructType: "claude", modality: "text+image->text"),
topProvider: "anthropic"
))
}