223 lines
7.7 KiB
Swift
223 lines
7.7 KiB
Swift
//
|
|
// ModelSelectorView.swift
|
|
// oAI
|
|
//
|
|
// Model selection screen
|
|
//
|
|
|
|
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 keyboardIndex: Int = -1
|
|
|
|
private var filteredModels: [ModelInfo] {
|
|
models.filter { model in
|
|
let matchesSearch = searchText.isEmpty ||
|
|
model.name.lowercased().contains(searchText.lowercased()) ||
|
|
model.id.lowercased().contains(searchText.lowercased())
|
|
|
|
let matchesVision = !filterVision || model.capabilities.vision
|
|
let matchesTools = !filterTools || model.capabilities.tools
|
|
let matchesOnline = !filterOnline || model.capabilities.online
|
|
let matchesImageGen = !filterImageGen || model.capabilities.imageGeneration
|
|
|
|
return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 0) {
|
|
// Search bar
|
|
TextField("Search models...", text: $searchText)
|
|
.textFieldStyle(.roundedBorder)
|
|
.padding()
|
|
.onChange(of: searchText) {
|
|
// Reset keyboard index when search changes
|
|
keyboardIndex = -1
|
|
}
|
|
|
|
// Filters
|
|
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")
|
|
}
|
|
.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
|
|
)
|
|
.id(model.id)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
onSelect(model)
|
|
}
|
|
}
|
|
.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 {
|
|
// Initialize keyboard index to current selection
|
|
if let selected = selectedModel,
|
|
let index = filteredModels.firstIndex(where: { $0.id == selected.id }) {
|
|
keyboardIndex = index
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct FilterToggle: View {
|
|
@Binding var isOn: Bool
|
|
let icon: String
|
|
let label: String
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
struct ModelRowView: View {
|
|
let model: ModelInfo
|
|
let isSelected: Bool
|
|
var isKeyboardHighlighted: Bool = false
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack {
|
|
Text(model.name)
|
|
.font(.headline)
|
|
.foregroundColor(isSelected ? .blue : .primary)
|
|
|
|
Spacer()
|
|
|
|
// Capabilities
|
|
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 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)
|
|
}
|
|
.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 }
|
|
)
|
|
}
|