Files
oai-swift/oAI/Views/Screens/ModelSelectorView.swift
2026-03-04 10:19:16 +01:00

332 lines
12 KiB
Swift

//
// ModelSelectorView.swift
// oAI
//
// Model selection screen
//
// 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 ModelSelectorView: View {
let models: [ModelInfo]
let selectedModel: ModelInfo?
let onSelect: (ModelInfo) -> Void
@Environment(\.dismiss) var dismiss
@State private var searchText = ""
@State private var filterVision = false
@State private var filterTools = false
@State private var filterOnline = false
@State private var filterImageGen = false
@State private var filterThinking = false
@State private var keyboardIndex: Int = -1
@State private var sortOrder: ModelSortOrder = .default
@State private var selectedInfoModel: ModelInfo? = nil
private var filteredModels: [ModelInfo] {
let q = searchText.lowercased()
let filtered = models.filter { model in
let matchesSearch = searchText.isEmpty ||
model.name.lowercased().contains(q) ||
model.id.lowercased().contains(q) ||
model.description?.lowercased().contains(q) == true
let matchesVision = !filterVision || model.capabilities.vision
let matchesTools = !filterTools || model.capabilities.tools
let matchesOnline = !filterOnline || model.capabilities.online
let matchesImageGen = !filterImageGen || model.capabilities.imageGeneration
let matchesThinking = !filterThinking || model.capabilities.thinking
return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen && matchesThinking
}
switch sortOrder {
case .default:
return filtered
case .priceLowHigh:
return filtered.sorted { $0.pricing.prompt < $1.pricing.prompt }
case .priceHighLow:
return filtered.sorted { $0.pricing.prompt > $1.pricing.prompt }
case .contextHighLow:
return filtered.sorted { $0.contextLength > $1.contextLength }
}
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Search bar
TextField("Search models...", text: $searchText)
.textFieldStyle(.roundedBorder)
.padding()
.onChange(of: searchText) {
keyboardIndex = -1
}
// Filters + Sort
HStack(spacing: 12) {
FilterToggle(isOn: $filterVision, icon: "\u{1F441}\u{FE0F}", label: "Vision")
FilterToggle(isOn: $filterTools, icon: "\u{1F527}", label: "Tools")
FilterToggle(isOn: $filterOnline, icon: "\u{1F310}", label: "Online")
FilterToggle(isOn: $filterImageGen, icon: "\u{1F3A8}", label: "Image Gen")
FilterToggle(isOn: $filterThinking, icon: "\u{1F9E0}", label: "Thinking")
Spacer()
// Sort menu
Menu {
ForEach(ModelSortOrder.allCases, id: \.rawValue) { order in
Button {
sortOrder = order
keyboardIndex = -1
} label: {
if sortOrder == order {
Label(order.label, systemImage: "checkmark")
} else {
Text(order.label)
}
}
}
} label: {
HStack(spacing: 4) {
Image(systemName: "arrow.up.arrow.down")
Text("Sort")
}
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(sortOrder != .default ? Color.blue.opacity(0.2) : Color.gray.opacity(0.1))
.foregroundColor(sortOrder != .default ? .blue : .secondary)
.cornerRadius(6)
}
.menuStyle(.borderlessButton)
.fixedSize()
}
.padding(.horizontal)
.padding(.bottom, 12)
Divider()
// Model list
if filteredModels.isEmpty {
ContentUnavailableView(
"No Models Found",
systemImage: "magnifyingglass",
description: Text("Try adjusting your search or filters")
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollViewReader { proxy in
List(Array(filteredModels.enumerated()), id: \.element.id) { index, model in
ModelRowView(
model: model,
isSelected: model.id == selectedModel?.id,
isKeyboardHighlighted: index == keyboardIndex,
onSelect: { onSelect(model) },
onInfo: { selectedInfoModel = model }
)
.id(model.id)
}
.listStyle(.plain)
.onChange(of: keyboardIndex) { _, newIndex in
if newIndex >= 0 && newIndex < filteredModels.count {
withAnimation {
proxy.scrollTo(filteredModels[newIndex].id, anchor: .center)
}
}
}
}
}
}
.frame(minWidth: 600, minHeight: 500)
.navigationTitle("Select Model")
#if os(macOS)
.onKeyPress(.downArrow) {
if keyboardIndex < filteredModels.count - 1 {
keyboardIndex += 1
}
return .handled
}
.onKeyPress(.upArrow) {
if keyboardIndex > 0 {
keyboardIndex -= 1
} else if keyboardIndex == -1 && !filteredModels.isEmpty {
keyboardIndex = 0
}
return .handled
}
.onKeyPress(.return) {
if keyboardIndex >= 0 && keyboardIndex < filteredModels.count {
onSelect(filteredModels[keyboardIndex])
return .handled
}
return .ignored
}
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
.onAppear {
if let selected = selectedModel,
let index = filteredModels.firstIndex(where: { $0.id == selected.id }) {
keyboardIndex = index
}
}
.sheet(item: $selectedInfoModel) { model in
ModelInfoView(model: model)
}
}
}
}
// MARK: - Sort Order
enum ModelSortOrder: String, CaseIterable {
case `default`
case priceLowHigh
case priceHighLow
case contextHighLow
var label: LocalizedStringKey {
switch self {
case .default: return "Default"
case .priceLowHigh: return "Price: Low to High"
case .priceHighLow: return "Price: High to Low"
case .contextHighLow: return "Context: High to Low"
}
}
}
// MARK: - Filter Toggle
struct FilterToggle: View {
@Binding var isOn: Bool
let icon: String
let label: LocalizedStringKey
var body: some View {
Button(action: { isOn.toggle() }) {
HStack(spacing: 4) {
Text(icon)
Text(label)
}
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(isOn ? Color.blue.opacity(0.2) : Color.gray.opacity(0.1))
.foregroundColor(isOn ? .blue : .secondary)
.cornerRadius(6)
}
.buttonStyle(.plain)
}
}
// MARK: - Model Row
struct ModelRowView: View {
let model: ModelInfo
let isSelected: Bool
var isKeyboardHighlighted: Bool = false
let onSelect: () -> Void
let onInfo: () -> Void
var body: some View {
HStack(alignment: .top, spacing: 8) {
// Selectable main content
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(model.name)
.font(.headline)
.foregroundColor(isSelected ? .blue : .primary)
if isSelected {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
}
}
Text(model.id)
.font(.caption)
.foregroundColor(.secondary)
if let description = model.description {
Text(description)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(2)
}
HStack(spacing: 12) {
Label(model.contextLengthDisplay, systemImage: "text.alignleft")
Label(model.promptPriceDisplay + "/1M", systemImage: "dollarsign.circle")
}
.font(.caption2)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture { onSelect() }
// Right side: capabilities + info button
VStack(alignment: .trailing, spacing: 6) {
// Capability icons
HStack(spacing: 4) {
if model.capabilities.vision { Text("\u{1F441}\u{FE0F}").font(.caption) }
if model.capabilities.tools { Text("\u{1F527}").font(.caption) }
if model.capabilities.online { Text("\u{1F310}").font(.caption) }
if model.capabilities.imageGeneration { Text("\u{1F3A8}").font(.caption) }
if model.capabilities.thinking { Text("\u{1F9E0}").font(.caption) }
}
// Info button
Button(action: onInfo) {
Image(systemName: "info.circle")
.font(.caption)
.foregroundColor(.secondary)
.frame(width: 20, height: 20)
}
.buttonStyle(.plain)
.help("Show model info")
}
.padding(.top, 2)
}
.padding(.vertical, 6)
.padding(.horizontal, isKeyboardHighlighted ? 4 : 0)
.background(
isKeyboardHighlighted
? RoundedRectangle(cornerRadius: 6).fill(Color.accentColor.opacity(0.15))
: nil
)
}
}
#Preview {
ModelSelectorView(
models: ModelInfo.mockModels,
selectedModel: ModelInfo.mockModels.first,
onSelect: { _ in }
)
}