Files
rune d1af2b902c Fetch Hugging Face's model catalog live instead of a static curated list
GET /v1/models on HF's router is public and returns every currently
routable model with real per-provider pricing, context length, and
tool support — confirmed by reading the live response, which
contradicts the original plan's assumption that no such catalog
existed. Replaces the static 7-model list as the primary source (kept
as a fallback for fetch failures); pricing shown is the cheapest live
backend per model, since HF itself routes to the fastest one by
default and displaying a range isn't supported by ModelInfo.Pricing.
2026-08-28 08:27:38 +02:00

388 lines
17 KiB
Swift

//
// ModelInfoView.swift
// Confab
//
// Rich model information modal
//
// 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 ModelInfoView: View {
let model: ModelInfo
@Environment(\.dismiss) var dismiss
@Bindable private var settings = SettingsService.shared
@State private var showDescriptionCopied = false
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Model Info")
.font(.system(size: 18, weight: .bold))
Spacer()
let isFav = settings.favoriteModelIds.contains(model.id)
Button(action: { settings.toggleFavoriteModel(model.id) }) {
Image(systemName: isFav ? "star.fill" : "star")
.font(.system(size: 18))
.foregroundColor(isFav ? .yellow : .secondary)
}
.buttonStyle(.plain)
.help(isFav ? "Remove from favorites" : "Add to favorites")
.padding(.trailing, 8)
Button { dismiss() } label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.keyboardShortcut(.escape, modifiers: [])
}
.padding(.horizontal, 24)
.padding(.top, 20)
.padding(.bottom, 12)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 20) {
// Overview
sectionHeader("Overview")
infoRow("Name", model.name)
infoRow("ID", model.id)
if let provider = model.topProvider {
infoRow("Provider", provider)
}
if let releaseDate = model.releaseDate {
infoRow("Released", releaseDate.formatted(date: .abbreviated, time: .omitted))
}
if let desc = model.description {
// Always shown in full, no truncate/expand toggle — Text with a lineLimit
// nested inside this view's ScrollView doesn't reliably compute wrapping/
// truncation (a well-documented SwiftUI/AppKit quirk: without a fixedSize
// hint it hard-clips mid-word with no ellipsis; with one, sibling views in
// the same VStack — like the former "More…" button — can silently fail to
// lay out). The modal itself already scrolls, so a long description just
// means more scrolling, which sidesteps the whole bug class.
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Text("Description")
.font(.subheadline.weight(.medium))
.foregroundColor(.secondary)
Spacer()
// A Copy button instead of .textSelection(.enabled): clicking into
// a multi-line selectable Text hands it real AppKit first-responder
// status, and Escape then gets consumed by that text view's own
// cancelOperation: handling before it ever reaches this modal's
// onExitCommand — the beep-instead-of-dismiss bug Rune reported.
// infoRow's single-line values keep .textSelection(.enabled); only
// this multi-line block reproduced the bug.
Button(action: copyDescription) {
HStack(spacing: 3) {
Image(systemName: showDescriptionCopied ? "checkmark" : "doc.on.doc")
.font(.system(size: 11))
if showDescriptionCopied {
Text("Copied!")
.font(.system(size: 11))
}
}
.foregroundColor(showDescriptionCopied ? .green : .secondary)
}
.buttonStyle(.plain)
}
Text(desc)
.font(.body)
.foregroundColor(.primary)
}
.padding(.leading, 4)
}
Divider()
// Pricing
sectionHeader("Pricing")
if model.id.hasPrefix(HuggingFaceProvider.idPrefix) && model.pricing.prompt == 0 {
Text("Pricing varies by backend provider — check your Hugging Face billing dashboard for actual cost.")
.font(.caption)
.foregroundColor(.secondary)
.padding(.leading, 4)
} else {
infoRow("Input", model.promptPriceDisplay + " / 1M tokens")
infoRow("Output", model.completionPriceDisplay + " / 1M tokens")
if model.id.hasPrefix(HuggingFaceProvider.idPrefix) {
Text("Cheapest available backend for this model — Hugging Face routes to the fastest one by default, so actual cost may differ slightly.")
.font(.caption)
.foregroundColor(.secondary)
.padding(.leading, 4)
}
}
if model.pricing.prompt > 0 {
VStack(alignment: .leading, spacing: 6) {
Text("Cost Examples")
.font(.caption)
.foregroundColor(.secondary)
HStack(spacing: 16) {
costExample(label: "1K tokens", inputTokens: 1_000)
costExample(label: "10K tokens", inputTokens: 10_000)
costExample(label: "100K tokens", inputTokens: 100_000)
}
}
.padding(.leading, 4)
}
Divider()
// Context Window
sectionHeader("Context Window")
infoRow("Max Tokens", model.contextLength.formatted())
if model.contextLength > 0 {
VStack(alignment: .leading, spacing: 4) {
let maxContext = 2_000_000.0
let fraction = min(Double(model.contextLength) / maxContext, 1.0)
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.gray.opacity(0.2))
.frame(height: 16)
GeometryReader { geo in
RoundedRectangle(cornerRadius: 4)
.fill(Color.blue)
.frame(width: geo.size.width * fraction, height: 16)
}
.frame(height: 16)
}
Text(model.contextLengthDisplay + " tokens")
.font(.caption2)
.foregroundColor(.secondary)
}
.padding(.leading, 4)
}
Divider()
// Capabilities
sectionHeader("Capabilities")
HStack(spacing: 12) {
capabilityBadge(icon: "eye.fill", label: "Vision", active: model.capabilities.vision)
capabilityBadge(icon: "wrench.fill", label: "Tools", active: model.capabilities.tools)
capabilityBadge(icon: "globe", label: "Online", active: model.capabilities.online)
capabilityBadge(icon: "photo.fill", label: "Image Gen", active: model.capabilities.imageGeneration)
capabilityBadge(icon: "brain", label: "Thinking", active: model.capabilities.thinking)
}
// Categories (if any)
if !model.categories.isEmpty {
Divider()
sectionHeader("Categories")
FlowLayout(spacing: 8) {
ForEach(model.categories, id: \.rawValue) { cat in
HStack(spacing: 5) {
Image(systemName: cat.systemImage)
.font(.caption2)
Text(LocalizedStringKey(cat.rawValue))
.font(.caption)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(cat.color.opacity(0.12))
.foregroundColor(cat.color)
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(cat.color.opacity(0.35), lineWidth: 1)
)
}
}
.padding(.leading, 4)
}
// Architecture (if available)
if let arch = model.architecture {
Divider()
sectionHeader("Architecture")
if let modality = arch.modality {
infoRow("Modality", modality)
}
if let tokenizer = arch.tokenizer {
infoRow("Tokenizer", tokenizer)
}
if let instructType = arch.instructType {
infoRow("Instruct Type", instructType)
}
}
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
Divider()
// Bottom bar
HStack {
Spacer()
Button("Done") { dismiss() }
.keyboardShortcut(.return, modifiers: [])
.buttonStyle(.borderedProminent)
.controlSize(.regular)
Spacer()
}
.padding(.horizontal, 24)
.padding(.vertical, 12)
}
.frame(minWidth: 550, idealWidth: 650, minHeight: 550, idealHeight: 750)
// Nearly every value in this modal has .textSelection(.enabled) (infoRow's value text,
// the description). Once one of those has text-selection focus, Escape can get
// intercepted by AppKit's text-selection machinery instead of reaching the close
// button's .keyboardShortcut(.escape) — no action is bound there, so it just beeps
// instead of dismissing. onExitCommand is macOS's dedicated hook for the "Escape/Cancel"
// user command and fires regardless of which child currently holds focus, so it's a more
// reliable place to handle this than a single button's keyboardShortcut alone.
.onExitCommand { dismiss() }
}
private func copyDescription() {
guard let desc = model.description else { return }
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(desc, forType: .string)
withAnimation {
showDescriptionCopied = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation {
showDescriptionCopied = false
}
}
}
// MARK: - Layout Helpers
private func sectionHeader(_ title: LocalizedStringKey) -> some View {
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
.textCase(.uppercase)
}
private func infoRow(_ label: LocalizedStringKey, _ value: String) -> some View {
HStack {
Text(label)
.font(.body)
Spacer()
Text(value)
.font(.body)
.foregroundColor(.secondary)
.textSelection(.enabled)
}
.padding(.leading, 4)
}
@ViewBuilder
private func costExample(label: LocalizedStringKey, inputTokens: Int) -> some View {
let cost = (Double(inputTokens) * model.pricing.prompt / 1_000_000) +
(Double(inputTokens) * model.pricing.completion / 1_000_000)
VStack(spacing: 2) {
Text(label)
.font(.caption2)
.foregroundColor(.secondary)
Text(String(format: "$%.4f", cost))
.font(.caption.monospacedDigit())
.foregroundColor(.primary)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.gray.opacity(0.1))
.cornerRadius(4)
}
@ViewBuilder
private func capabilityBadge(icon: String, label: LocalizedStringKey, active: Bool) -> some View {
VStack(spacing: 4) {
Image(systemName: icon)
.font(.title3)
.foregroundColor(active ? .blue : .gray.opacity(0.4))
Text(label)
.font(.caption2)
.foregroundColor(active ? .primary : .secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.background(active ? Color.blue.opacity(0.1) : Color.gray.opacity(0.05))
.cornerRadius(8)
}
}
// MARK: - Flow Layout
struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
let rows = rows(for: subviews, width: proposal.width ?? .infinity)
let height = rows.map { row in
row.map { $0.sizeThatFits(.unspecified).height }.max() ?? 0
}.reduce(0, +) + CGFloat(max(0, rows.count - 1)) * spacing
return CGSize(width: proposal.width ?? 0, height: height)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
let rows = rows(for: subviews, width: bounds.width)
var y = bounds.minY
for row in rows {
var x = bounds.minX
let rowH = row.map { $0.sizeThatFits(.unspecified).height }.max() ?? 0
for sub in row {
let size = sub.sizeThatFits(.unspecified)
sub.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
x += size.width + spacing
}
y += rowH + spacing
}
}
private func rows(for subviews: Subviews, width: CGFloat) -> [[LayoutSubview]] {
var rows: [[LayoutSubview]] = [[]]
var rowWidth: CGFloat = 0
for sub in subviews {
let w = sub.sizeThatFits(.unspecified).width
if rowWidth + w > width, !rows.last!.isEmpty {
rows.append([])
rowWidth = 0
}
rows[rows.count - 1].append(sub)
rowWidth += w + spacing
}
return rows
}
}
#Preview {
ModelInfoView(model: ModelInfo(
id: "anthropic/claude-sonnet-4",
name: "Claude Sonnet 4",
description: "Balanced intelligence and speed. This is a longer description to test how the modal handles multi-line text that wraps across several lines in the description field.",
contextLength: 200_000,
pricing: .init(prompt: 3.0, completion: 15.0),
capabilities: .init(vision: true, tools: true, online: false),
architecture: .init(tokenizer: "claude", instructType: "claude", modality: "text+image->text"),
topProvider: "anthropic"
))
}