SPB Git

spb/zyquo-agent Public MIT

The autonomous agent that actually operates your Mac — plans, runs real commands, verifies its own work.

Swift 94.7% Shell 4.1% Python 0.7% Makefile 0.5%
7.3 KB · 178 lines swift
Raw Blame History
1//2//  ModelsSettingsTab.swift3//  Zyquo Agent4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Settings › Models — the full shared Zyquo Cloud catalog grouped by9//  provider, with the Agent twist: agent-capable models carry an "Agent"10//  badge, and the star sets the persisted default agent model used by new11//  tasks. Per-model info: context window, capability flags, pricing.12//1314import SwiftUI1516struct ModelsSettingsTab: View {17    @EnvironmentObject private var catalog: ModelCatalog18    @EnvironmentObject private var vault: KeyVaultStore19    @EnvironmentObject private var settings: AgentSettingsStore20    @State private var selectedProvider: ProviderID = AgentModelSupport.defaultModelProvider21    @State private var refreshing = false22    @State private var refreshResult: String?2324    var body: some View {25        VStack(spacing: 0) {26            HStack {27                Picker("Provider", selection: $selectedProvider) {28                    ForEach(ProviderID.builtIn) { provider in29                        Text(provider.displayName).tag(provider)30                    }31                }32                .frame(width: 240)33                Spacer()34                if let result = refreshResult {35                    Text(result)36                        .font(ZyquoFont.caption)37                        .foregroundStyle(ZyquoColor.textSecondary)38                        .lineLimit(1)39                }40                Button {41                    refreshModels()42                } label: {43                    if refreshing {44                        ProgressView().controlSize(.small)45                    } else {46                        Label("Refresh from API", systemImage: "arrow.clockwise")47                    }48                }49                .controlSize(.small)50                .disabled(refreshing || !selectedProvider.supportsModelListing || !vault.hasKey(for: selectedProvider))51            }52            .padding(ZyquoSpacing.sm)53            ZyquoHairline()54            defaultBanner55            ZyquoHairline()56            modelTable57        }58        .background(ZyquoColor.background)59    }6061    private var defaultBanner: some View {62        HStack(spacing: ZyquoSpacing.xs) {63            Image(systemName: "star.fill")64                .font(.system(size: 10))65                .foregroundStyle(ZyquoColor.warning)66            if let model = settings.defaultAgentModel(in: catalog) {67                Text("Default agent model: \(model.displayName) (\(model.provider.displayName)) — new tasks start with it. Star another agent-capable model to change.")68                    .font(ZyquoFont.caption)69                    .foregroundStyle(ZyquoColor.textSecondary)70            } else {71                Text("No default agent model — star an agent-capable model below.")72                    .font(ZyquoFont.caption)73                    .foregroundStyle(ZyquoColor.textSecondary)74            }75            Spacer(minLength: 0)76        }77        .padding(.horizontal, ZyquoSpacing.sm)78        .padding(.vertical, ZyquoSpacing.xxs)79    }8081    private var modelTable: some View {82        ScrollView {83            LazyVStack(spacing: 1) {84                ForEach(catalog.models(for: selectedProvider)) { model in85                    modelRow(model)86                }87                let unknown = catalog.unknownLiveIDs(for: selectedProvider)88                if !unknown.isEmpty {89                    Text("Live on the API but not in the catalog: \(unknown.joined(separator: ", "))")90                        .font(ZyquoFont.caption)91                        .foregroundStyle(ZyquoColor.textTertiary)92                        .frame(maxWidth: .infinity, alignment: .leading)93                        .padding(ZyquoSpacing.sm)94                }95            }96            .padding(ZyquoSpacing.sm)97        }98    }99100    private func modelRow(_ model: AIModel) -> some View {101        let isDefault = isDefaultAgentModel(model)102        return HStack(spacing: ZyquoSpacing.xs) {103            Button {104                settings.setDefaultAgentModel(model)105            } label: {106                Image(systemName: isDefault ? "star.fill" : "star")107                    .font(.system(size: 10))108                    .foregroundStyle(isDefault ? ZyquoColor.warning : ZyquoColor.textTertiary)109            }110            .buttonStyle(.plain)111            .disabled(!model.agentCapable)112            .help(model.agentCapable ? "Set as the default agent model" : "Not agent-capable — cannot be the default")113            VStack(alignment: .leading, spacing: 0) {114                HStack(spacing: ZyquoSpacing.xxs) {115                    Text(model.displayName)116                        .font(ZyquoFont.body(size: 12.5))117                        .foregroundStyle(model.agentCapable ? ZyquoColor.textPrimary : ZyquoColor.textSecondary)118                    if model.agentCapable {119                        ZyquoBadge(text: "Agent", color: ZyquoColor.accent)120                            .help("Verified for deep agentic, multi-step tool use")121                    }122                    if model.isRecommended { ZyquoBadge(text: "Featured", color: ZyquoColor.success) }123                    if model.isLegacy { ZyquoBadge(text: "Legacy") }124                }125                Text(model.id)126                    .font(ZyquoFont.code(size: 10))127                    .foregroundStyle(ZyquoColor.textTertiary)128            }129            Spacer()130            HStack(spacing: ZyquoSpacing.xxs) {131                if model.capabilities.tools { ZyquoBadge(text: "tools") }132                if model.capabilities.vision { ZyquoBadge(text: "vision") }133                if model.capabilities.reasoning { ZyquoBadge(text: "reasoning") }134            }135            Text(model.contextBadge)136                .font(ZyquoFont.caption)137                .foregroundStyle(ZyquoColor.textSecondary)138                .frame(width: 64, alignment: .trailing)139            Text(pricingText(model))140                .font(ZyquoFont.caption)141                .foregroundStyle(ZyquoColor.textTertiary)142                .frame(width: 100, alignment: .trailing)143                .help("USD per 1M tokens, input / output (estimates)")144        }145        .padding(.vertical, 3)146        .padding(.horizontal, ZyquoSpacing.xs)147        .zyquoHoverHighlight()148    }149150    private func isDefaultAgentModel(_ model: AIModel) -> Bool {151        let current = settings.defaultAgentModel(in: catalog)152        return current?.id == model.id && current?.provider == model.provider153    }154155    private func pricingText(_ model: AIModel) -> String {156        guard let pricing = model.pricing else { return "—" }157        return String(format: "$%.2f / $%.2f", pricing.inputPerMTok, pricing.outputPerMTok)158    }159160    private func refreshModels() {161        refreshing = true162        refreshResult = nil163        let provider = selectedProvider164        Task {165            defer { refreshing = false }166            do {167                let key = try vault.apiKey(for: provider)168                let ids = try await ProviderRegistry.client(for: provider).listModelIDs(apiKey: key)169                catalog.applyLiveListing(ids, for: provider)170                let unknown = catalog.unknownLiveIDs(for: provider).count171                refreshResult = "\(ids.count) live models · \(unknown) not in catalog"172            } catch {173                refreshResult = error.localizedDescription174            }175        }176    }177}178