Files
oai-swift/oAI/Views/Screens/ModelSelectorView.swift
rune 57b6ab027e Explain HF's per-provider BYOK requirement in the Custom Model ID sheet and chat errors
Some Hugging Face backends (e.g. Featherless AI) only route requests
if the user has added their own API key for that provider at HF's
settings — surfaced by Confab as a bare "not supported by any
provider you have enabled" error with no indication why. Enriches
that specific error with guidance, and adds the same note to the
Custom Model ID entry point. Also converted the alert-based Custom
Model ID dialog to a real sheet since SwiftUI alerts can't be resized
and it read as cramped.
2026-08-28 08:56:08 +02:00

555 lines
22 KiB
Swift

//
// ModelSelectorView.swift
// Confab
//
// Model selection screen
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of Confab.
//
// Confab 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 Confab 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://confab.no>.
import SwiftUI
struct ModelSelectorView: View {
let models: [ModelInfo]
let selectedModel: ModelInfo?
let onSelect: (ModelInfo) -> Void
/// When non-nil, shows a "Custom Model ID…" entry point (e.g. for Hugging Face, whose model
/// catalog isn't fetchable live). Called with the raw typed ID; the caller builds the ModelInfo.
var onCustomModelID: ((String) -> Void)? = nil
@Environment(\.dismiss) var dismiss
@State private var searchText = ""
@State private var showCustomModelAlert = false
@State private var customModelIDText = ""
@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 selectedCategory: ModelCategory? = nil
@State private var showCategoryPicker = 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 categoriesWithModels: Set<ModelCategory> {
Set(models.flatMap(\.categories))
}
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)
let matchesCategory = selectedCategory == nil || model.categories.contains(selectedCategory!)
return matchesSearch && matchesVision && matchesTools && matchesOnline && matchesImageGen && matchesThinking && matchesFavorites && matchesCategory
}
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
GlassEffectContainer(spacing: 12) {
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()
// Category picker (only shown when at least one category has models)
if !categoriesWithModels.isEmpty {
Button(action: { showCategoryPicker.toggle() }) {
HStack(spacing: 4) {
if let cat = selectedCategory {
Circle().fill(cat.color).frame(width: 7, height: 7)
Text(LocalizedStringKey(cat.rawValue))
} else {
Image(systemName: "tag")
Text("Category")
}
}
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.glassEffect(.regular, in: .rect(cornerRadius: 6))
.foregroundColor(selectedCategory != nil ? selectedCategory!.color : .secondary)
}
.buttonStyle(.plain)
.help("Filter by category")
.popover(isPresented: $showCategoryPicker, arrowEdge: .bottom) {
CategoryPickerPopover(
categoriesWithModels: categoriesWithModels,
selectedCategory: $selectedCategory,
onSelect: { keyboardIndex = -1 }
)
}
} // end if !categoriesWithModels.isEmpty
// Custom model ID entry (e.g. Hugging Face, whose catalog isn't fetchable live)
if onCustomModelID != nil {
Button(action: { customModelIDText = ""; showCustomModelAlert = true }) {
HStack(spacing: 4) {
Image(systemName: "plus")
Text("Custom Model ID…")
}
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.glassEffect(.regular, in: .rect(cornerRadius: 6))
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
.help("Enter a model ID not in the curated list")
}
// Favorites filter star
Button(action: { filterFavorites.toggle(); keyboardIndex = -1 }) {
Image(systemName: filterFavorites ? "star.fill" : "star")
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.glassEffect(.regular, in: .rect(cornerRadius: 6))
.foregroundColor(filterFavorites ? .yellow : .secondary)
}
.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)
.glassEffect(.regular, in: .rect(cornerRadius: 6))
.foregroundColor(sortOrder != .default ? .blue : .secondary)
}
.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: 740, 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)
}
.sheet(isPresented: $showCustomModelAlert) {
CustomModelIDSheet(
modelID: $customModelIDText,
onAdd: {
onCustomModelID?(customModelIDText)
showCustomModelAlert = false
},
onCancel: { showCustomModelAlert = false }
)
}
}
}
}
// MARK: - Custom Model ID Sheet
struct CustomModelIDSheet: View {
@Binding var modelID: String
let onAdd: () -> Void
let onCancel: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Custom Model ID")
.font(.system(size: 17, weight: .semibold))
Text("Enter a Hugging Face model ID, e.g. meta-llama/Llama-3.1-8B-Instruct")
.font(.system(size: 13))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
TextField("org/model-name", text: $modelID)
.textFieldStyle(.roundedBorder)
.onSubmit(onAdd)
Text("Some models are only served by providers that require your own API key, set at huggingface.co/settings/inference-providers. If adding a model fails, check there for that provider, or pick one from Confab's model list instead.")
.font(.system(size: 12))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
HStack {
Spacer()
Button("Cancel", role: .cancel, action: onCancel)
Button("Add", action: onAdd)
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
.disabled(modelID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.padding(24)
.frame(minWidth: 440)
}
}
// 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)
.lineLimit(1)
}
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.glassEffect(.regular, in: .rect(cornerRadius: 6))
.foregroundColor(isOn ? .blue : .secondary)
}
.buttonStyle(.plain)
.fixedSize()
}
}
// MARK: - Category Picker Popover
struct CategoryPickerPopover: View {
let categoriesWithModels: Set<ModelCategory>
@Binding var selectedCategory: ModelCategory?
let onSelect: () -> Void
private let columns = [GridItem(.adaptive(minimum: 130), spacing: 8)]
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Filter by Category")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Spacer()
if selectedCategory != nil {
Button("Clear") {
selectedCategory = nil
onSelect()
}
.font(.caption)
.buttonStyle(.plain)
.foregroundStyle(.blue)
}
}
LazyVGrid(columns: columns, spacing: 8) {
ForEach(ModelCategory.allCases.filter { categoriesWithModels.contains($0) }, id: \.rawValue) { cat in
let isSelected = selectedCategory == cat
Button {
selectedCategory = isSelected ? nil : cat
onSelect()
} label: {
HStack(spacing: 6) {
Image(systemName: cat.systemImage)
.font(.caption)
.frame(width: 14)
Text(LocalizedStringKey(cat.rawValue))
.font(.caption)
Spacer()
}
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(isSelected ? cat.color.opacity(0.18) : Color.gray.opacity(0.08))
.foregroundColor(isSelected ? cat.color : .primary)
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(isSelected ? cat.color.opacity(0.45) : Color.clear, lineWidth: 1)
)
}
.buttonStyle(.plain)
}
}
}
.padding(14)
.frame(minWidth: 360)
}
}
// 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")
if model.id.hasPrefix(HuggingFaceProvider.idPrefix) && model.pricing.prompt == 0 {
Label("Varies", systemImage: "dollarsign.circle")
.help("Pricing varies by backend provider — check your Hugging Face billing dashboard")
} else if model.id.hasPrefix(HuggingFaceProvider.idPrefix) {
Label(model.promptPriceDisplay + "/1M", systemImage: "dollarsign.circle")
.help("Cheapest available backend — the actual provider used may differ")
} else {
Label(model.promptPriceDisplay + "/1M", systemImage: "dollarsign.circle")
}
}
.font(.caption2)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture { onSelect() }
// Right side: capabilities + category dots + 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) }
}
// Category dots
if !model.categories.isEmpty {
HStack(spacing: 3) {
ForEach(model.categories, id: \.rawValue) { cat in
Circle()
.fill(cat.color)
.frame(width: 7, height: 7)
.help(cat.rawValue)
}
}
}
// 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 }
)
}