SPB Git

spb/zyquo-local Public MIT

Native macOS AI chat that runs LLMs 100% locally on Apple Silicon with MLX — no cloud, no API keys.

Swift 97.2% Shell 1.8% Makefile 1%
8.3 KB · 221 lines swift
Raw Blame History
1//2//  DiscoverView.swift3//  Zyquo Local4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import SwiftUI1011/// Discover tab: Featured curated catalog + live Hub search with scopes,12/// filters and sort. Cards flip into live download state.13struct DiscoverView: View {14    @Environment(AppModel.self) private var app15    @State private var query = ""16    @State private var scope: HubService.Scope = .featured17    @State private var sort: HubService.Sort = .downloads18    @State private var sizeFilter: SizeClass = .any19    @State private var results: [HubService.ModelSummary] = []20    @State private var searching = false21    @State private var searchError: String?22    @State private var searchTask: Task<Void, Never>?2324    enum SizeClass: String, CaseIterable {25        case any = "Any size"26        case tiny = "≤4B"27        case mid = "7–14B"28        case large = "24B+"29    }3031    var body: some View {32        VStack(spacing: 0) {33            controls34            Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline)3536            if scope == .featured && query.isEmpty {37                featuredList38            } else {39                searchResults40            }41        }42        .onChange(of: query) { debounceSearch() }43        .onChange(of: scope) { debounceSearch() }44        .onChange(of: sort) { debounceSearch() }45    }4647    private var controls: some View {48        HStack(spacing: ZyquoTheme.Spacing.s) {49            HStack(spacing: ZyquoTheme.Spacing.xxs) {50                Image(systemName: "magnifyingglass")51                    .font(.system(size: 11))52                    .foregroundStyle(ZyquoTheme.textTertiary)53                TextField("Search Hugging Face…", text: $query)54                    .textFieldStyle(.plain)55                    .font(ZyquoTheme.body)56                if searching {57                    ProgressView().controlSize(.mini)58                }59            }60            .padding(.horizontal, ZyquoTheme.Spacing.xs)61            .padding(.vertical, 5)62            .frame(maxWidth: 280)63            .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s))64            .overlay(65                RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s)66                    .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline)67            )6869            Picker("", selection: $scope) {70                Text("Featured").tag(HubService.Scope.featured)71                Text("mlx-community").tag(HubService.Scope.mlxCommunity)72                Text("All MLX").tag(HubService.Scope.allMLX)73            }74            .pickerStyle(.segmented)75            .frame(width: 300)7677            Spacer()7879            Picker("Size", selection: $sizeFilter) {80                ForEach(SizeClass.allCases, id: \.self) { Text($0.rawValue).tag($0) }81            }82            .frame(width: 120)8384            Picker("Sort", selection: $sort) {85                Text("Downloads").tag(HubService.Sort.downloads)86                Text("Likes").tag(HubService.Sort.likes)87                Text("Newest").tag(HubService.Sort.newest)88            }89            .frame(width: 130)90            .disabled(scope == .featured && query.isEmpty)91        }92        .padding(ZyquoTheme.Spacing.s)93    }9495    // MARK: - Featured9697    private var featuredList: some View {98        ScrollView {99            LazyVStack(alignment: .leading, spacing: ZyquoTheme.Spacing.l) {100                Text("Hand-picked models, live-verified for this Mac")101                    .font(ZyquoTheme.caption)102                    .foregroundStyle(ZyquoTheme.textTertiary)103                    .padding(.top, ZyquoTheme.Spacing.m)104105                ForEach(featuredSections, id: \.title) { section in106                    if !section.models.isEmpty {107                        VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.s) {108                            Text(section.title)109                                .font(ZyquoTheme.bodyEmphasis)110                                .foregroundStyle(ZyquoTheme.textPrimary)111                            ForEach(section.models) { model in112                                FeaturedModelCard(model: model)113                            }114                        }115                    }116                }117            }118            .padding(.horizontal, ZyquoTheme.Spacing.l)119            .padding(.bottom, ZyquoTheme.Spacing.l)120            .frame(maxWidth: 860)121            .frame(maxWidth: .infinity)122        }123    }124125    private var featuredSections: [(title: String, models: [CatalogModel])] {126        func matchesSize(_ m: CatalogModel) -> Bool {127            switch sizeFilter {128            case .any: true129            case .tiny: m.categories.contains(.tiny)130            case .mid: m.categories.contains(.mid)131            case .large: m.categories.contains(.large)132            }133        }134        let featured = ModelCatalog.featured.filter(matchesSize)135        return [136            ("Starter picks for this Mac", ModelCatalog.starterPicks().filter(matchesSize)),137            ("General", featured.filter { $0.categories.contains(.mid) || $0.categories.contains(.large) }138                .filter { !$0.categories.contains(.coding) && !$0.categories.contains(.reasoning) }),139            ("Small & fast", featured.filter { $0.categories.contains(.tiny) }),140            ("Coding", featured.filter { $0.categories.contains(.coding) }),141            ("Reasoning", featured.filter { $0.categories.contains(.reasoning) }),142        ]143    }144145    // MARK: - Live search146147    private var searchResults: some View {148        ScrollView {149            LazyVStack(spacing: ZyquoTheme.Spacing.s) {150                if let error = searchError {151                    Text(error)152                        .font(ZyquoTheme.body)153                        .foregroundStyle(ZyquoTheme.danger)154                        .padding(.top, ZyquoTheme.Spacing.xl)155                } else if results.isEmpty && !searching {156                    Text(query.isEmpty ? "Type to search the Hub" : "No MLX models found for “\(query)”")157                        .font(ZyquoTheme.body)158                        .foregroundStyle(ZyquoTheme.textTertiary)159                        .padding(.top, ZyquoTheme.Spacing.xl)160                }161                ForEach(filteredResults) { summary in162                    HubModelCard(summary: summary)163                }164            }165            .padding(ZyquoTheme.Spacing.l)166            .frame(maxWidth: 860)167            .frame(maxWidth: .infinity)168        }169    }170171    private var filteredResults: [HubService.ModelSummary] {172        results.filter { summary in173            switch sizeFilter {174            case .any: return true175            case .tiny:176                return paramsBillions(summary.id).map { $0 <= 4.5 } ?? true177            case .mid:178                return paramsBillions(summary.id).map { $0 > 4.5 && $0 <= 20 } ?? true179            case .large:180                return paramsBillions(summary.id).map { $0 > 20 } ?? true181            }182        }183    }184185    /// Parses "…-7B-…"/"…-0.6B-…" from a repo name.186    private func paramsBillions(_ repoID: String) -> Double? {187        let name = shortModelName(repoID)188        guard let regex = try? NSRegularExpression(pattern: #"(\d+(?:\.\d+)?)[Bb]"#) else { return nil }189        let range = NSRange(name.startIndex..., in: name)190        guard let match = regex.firstMatch(in: name, range: range),191            let r = Range(match.range(at: 1), in: name)192        else { return nil }193        return Double(name[r])194    }195196    private func debounceSearch() {197        searchTask?.cancel()198        guard !(scope == .featured && query.isEmpty) else { return }199        searchTask = Task {200            try? await Task.sleep(for: .milliseconds(350))201            guard !Task.isCancelled else { return }202            await runSearch()203        }204    }205206    private func runSearch() async {207        searching = true208        searchError = nil209        do {210            let hub = HubService(token: app.settings.hfToken.isEmpty ? nil : app.settings.hfToken)211            let effectiveScope: HubService.Scope = scope == .featured ? .mlxCommunity : scope212            results = try await hub.search(query: query, scope: effectiveScope, sort: sort)213        } catch is CancellationError {214        } catch {215            searchError = error.localizedDescription216            results = []217        }218        searching = false219    }220}221