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.
555 lines
22 KiB
Swift
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 }
|
|
)
|
|
}
|