13699864d8
- Jarvis integration: manage oAI-Web agents and usage from inside the app (/jarvis command, Settings tab 11) - Model category filter: keyword-based categorisation with popover picker in model selector - Categories shown in ModelInfoView with coloured chips; dot indicators on model rows Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
323 lines
13 KiB
Swift
323 lines
13 KiB
Swift
//
|
|
// ModelInfoView.swift
|
|
// oAI
|
|
//
|
|
// Rich model information modal
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// Copyright (C) 2026 Rune Olsen
|
|
//
|
|
// This file is part of oAI.
|
|
//
|
|
// oAI is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as
|
|
// published by the Free Software Foundation, either version 3 of the
|
|
// License, or (at your option) any later version.
|
|
//
|
|
// oAI is distributed in the hope that it will be useful, but WITHOUT
|
|
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
|
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
|
|
// Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public
|
|
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
|
|
import SwiftUI
|
|
|
|
struct ModelInfoView: View {
|
|
let model: ModelInfo
|
|
|
|
@Environment(\.dismiss) var dismiss
|
|
@Bindable private var settings = SettingsService.shared
|
|
|
|
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)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.textSelection(.enabled)
|
|
}
|
|
.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"
|
|
))
|
|
}
|