SPB Git

spb/zyquo-cloud Public MIT

Native macOS AI chat client for 12 cloud providers — your keys, every cloud model, one beautiful chat.

Swift 97.4% Shell 1.7% Makefile 1%
6.9 KB · 187 lines swift
Raw Blame History
1//2//  ModelChipView.swift3//  Zyquo Cloud4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  The model chip (provider glyph + model name) and its picker popover:9//  search, favorites first, grouped by provider, capability badges.10//1112import SwiftUI1314struct ModelChipView: View {15    let model: AIModel?16    var onSelect: (AIModel) -> Void1718    @State private var showingPicker = false1920    var body: some View {21        Button {22            showingPicker.toggle()23        } label: {24            HStack(spacing: ZyquoSpacing.xxs) {25                Image(systemName: model?.provider.symbolName ?? "questionmark.circle")26                    .font(.system(size: 11, weight: .medium))27                    .foregroundStyle(ZyquoColor.accent)28                Text(model?.displayName ?? "Choose Model")29                    .font(ZyquoFont.bodyEmphasis(size: 12.5))30                    .foregroundStyle(ZyquoColor.textPrimary)31                    .lineLimit(1)32                Image(systemName: "chevron.down")33                    .font(.system(size: 8, weight: .semibold))34                    .foregroundStyle(ZyquoColor.textTertiary)35            }36            .padding(.horizontal, ZyquoSpacing.xs)37            .padding(.vertical, 4)38            .background(39                RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)40                    .fill(ZyquoColor.surfaceSecondary)41            )42        }43        .buttonStyle(PressableButtonStyle())44        .popover(isPresented: $showingPicker, arrowEdge: .bottom) {45            ModelPickerView(selected: model) { chosen in46                showingPicker = false47                onSelect(chosen)48            }49        }50    }51}5253/// Model picker popover content: search field + favorites + provider groups.54struct ModelPickerView: View {55    let selected: AIModel?56    var onSelect: (AIModel) -> Void5758    @EnvironmentObject private var catalog: ModelCatalog59    @EnvironmentObject private var vault: KeyVaultStore60    @State private var query = ""6162    var body: some View {63        VStack(spacing: 0) {64            HStack(spacing: ZyquoSpacing.xxs) {65                Image(systemName: "magnifyingglass")66                    .font(.system(size: 11))67                    .foregroundStyle(ZyquoColor.textTertiary)68                TextField("Search models", text: $query)69                    .textFieldStyle(.plain)70                    .font(ZyquoFont.body(size: 12.5))71            }72            .padding(ZyquoSpacing.xs)73            ZyquoHairline()74            ScrollView {75                LazyVStack(alignment: .leading, spacing: 1) {76                    if !favorites.isEmpty && query.isEmpty {77                        sectionHeader("Favorites")78                        ForEach(favorites) { model in row(model) }79                    }80                    ForEach(providerGroups, id: \.0) { provider, models in81                        sectionHeader(provider.displayName)82                        ForEach(models) { model in row(model) }83                    }84                }85                .padding(ZyquoSpacing.xxs)86            }87            .frame(width: 320, height: 380)88        }89        .background(ZyquoColor.surface)90    }9192    private var favorites: [AIModel] {93        catalog.all.filter { catalog.favoriteIDs.contains($0.id) }94    }9596    /// Providers with a saved key first, then the rest; models filtered by search.97    private var providerGroups: [(ProviderID, [AIModel])] {98        let ordered = ProviderID.builtIn.sorted {99            (vault.hasKey(for: $0) ? 0 : 1, $0.displayName) < (vault.hasKey(for: $1) ? 0 : 1, $1.displayName)100        }101        return ordered.compactMap { provider in102            var models = catalog.models(for: provider)103            if !query.isEmpty {104                models = models.filter {105                    $0.displayName.localizedCaseInsensitiveContains(query)106                        || $0.id.localizedCaseInsensitiveContains(query)107                }108            }109            return models.isEmpty ? nil : (provider, models)110        }111    }112113    private func sectionHeader(_ title: String) -> some View {114        Text(title)115            .font(ZyquoFont.caption)116            .foregroundStyle(ZyquoColor.textTertiary)117            .padding(.horizontal, ZyquoSpacing.xs)118            .padding(.top, ZyquoSpacing.xs)119            .padding(.bottom, 2)120    }121122    private func row(_ model: AIModel) -> some View {123        Button {124            onSelect(model)125        } label: {126            HStack(spacing: ZyquoSpacing.xs) {127                Image(systemName: model.provider.symbolName)128                    .font(.system(size: 11))129                    .foregroundStyle(ZyquoColor.accent)130                    .frame(width: 14)131                Text(model.displayName)132                    .font(ZyquoFont.body(size: 12.5))133                    .foregroundStyle(ZyquoColor.textPrimary)134                    .lineLimit(1)135                Spacer(minLength: ZyquoSpacing.xs)136                capabilityBadges(model)137                Text(model.contextBadge)138                    .font(ZyquoFont.caption)139                    .foregroundStyle(ZyquoColor.textTertiary)140                Button {141                    toggleFavorite(model)142                } label: {143                    Image(systemName: catalog.favoriteIDs.contains(model.id) ? "star.fill" : "star")144                        .font(.system(size: 10))145                        .foregroundStyle(146                            catalog.favoriteIDs.contains(model.id) ? ZyquoColor.warning : ZyquoColor.textTertiary147                        )148                }149                .buttonStyle(.plain)150            }151            .padding(.horizontal, ZyquoSpacing.xs)152            .padding(.vertical, 4)153            .background(154                RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)155                    .fill(model.id == selected?.id && model.provider == selected?.provider156                        ? ZyquoColor.accentSubtle : .clear)157            )158            .contentShape(Rectangle())159        }160        .buttonStyle(.plain)161        .zyquoHoverHighlight()162    }163164    @ViewBuilder165    private func capabilityBadges(_ model: AIModel) -> some View {166        HStack(spacing: 3) {167            if model.capabilities.vision {168                Image(systemName: "eye").font(.system(size: 9)).foregroundStyle(ZyquoColor.textTertiary)169            }170            if model.capabilities.reasoning {171                Image(systemName: "brain").font(.system(size: 9)).foregroundStyle(ZyquoColor.textTertiary)172            }173            if model.capabilities.tools {174                Image(systemName: "wrench").font(.system(size: 9)).foregroundStyle(ZyquoColor.textTertiary)175            }176        }177    }178179    private func toggleFavorite(_ model: AIModel) {180        if catalog.favoriteIDs.contains(model.id) {181            catalog.favoriteIDs.remove(model.id)182        } else {183            catalog.favoriteIDs.insert(model.id)184        }185    }186}187