367 lines
14 KiB
Swift
367 lines
14 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 filterFavorites = false
|
|
@State private var keyboardIndex: Int = -1
|
|
@State private var sortOrder: ModelSortOrder = .default
|
|
@State private var selectedInfoModel: ModelInfo? = nil
|
|
@Bindable private var settings = SettingsService.shared
|
|
|
|
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
|
|
let matchesFavorites = !filterFavorites || settings.favoriteModelIds.contains(model.id)
|
|
|
|
return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen && matchesThinking && matchesFavorites
|
|
}
|
|
|
|
let favIds = settings.favoriteModelIds
|
|
switch sortOrder {
|
|
case .default:
|
|
return filtered.sorted { a, b in
|
|
let aFav = favIds.contains(a.id)
|
|
let bFav = favIds.contains(b.id)
|
|
if aFav != bFav { return aFav }
|
|
return false
|
|
}
|
|
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()
|
|
|
|
// Favorites filter star
|
|
Button(action: { filterFavorites.toggle(); keyboardIndex = -1 }) {
|
|
Image(systemName: filterFavorites ? "star.fill" : "star")
|
|
.font(.caption)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.background(filterFavorites ? Color.yellow.opacity(0.25) : Color.gray.opacity(0.1))
|
|
.foregroundColor(filterFavorites ? .yellow : .secondary)
|
|
.cornerRadius(6)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Show favorites only")
|
|
|
|
// 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,
|
|
isFavorite: settings.favoriteModelIds.contains(model.id),
|
|
onSelect: { onSelect(model) },
|
|
onFavorite: { settings.toggleFavoriteModel(model.id) },
|
|
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
|
|
var isFavorite: Bool = false
|
|
let onSelect: () -> Void
|
|
var onFavorite: (() -> Void)? = nil
|
|
let onInfo: () -> Void
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 8) {
|
|
// Selectable main content
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(spacing: 6) {
|
|
if let onFavorite {
|
|
Button(action: onFavorite) {
|
|
Image(systemName: isFavorite ? "star.fill" : "star")
|
|
.font(.system(size: 13))
|
|
.foregroundColor(isFavorite ? .yellow : .secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help(isFavorite ? "Remove from favorites" : "Add to favorites")
|
|
}
|
|
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 }
|
|
)
|
|
}
|