// // DiscoverView.swift // Zyquo Local // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import SwiftUI /// Discover tab: Featured curated catalog + live Hub search with scopes, /// filters and sort. Cards flip into live download state. struct DiscoverView: View { @Environment(AppModel.self) private var app @State private var query = "" @State private var scope: HubService.Scope = .featured @State private var sort: HubService.Sort = .downloads @State private var sizeFilter: SizeClass = .any @State private var results: [HubService.ModelSummary] = [] @State private var searching = false @State private var searchError: String? @State private var searchTask: Task? enum SizeClass: String, CaseIterable { case any = "Any size" case tiny = "≤4B" case mid = "7–14B" case large = "24B+" } var body: some View { VStack(spacing: 0) { controls Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline) if scope == .featured && query.isEmpty { featuredList } else { searchResults } } .onChange(of: query) { debounceSearch() } .onChange(of: scope) { debounceSearch() } .onChange(of: sort) { debounceSearch() } } private var controls: some View { HStack(spacing: ZyquoTheme.Spacing.s) { HStack(spacing: ZyquoTheme.Spacing.xxs) { Image(systemName: "magnifyingglass") .font(.system(size: 11)) .foregroundStyle(ZyquoTheme.textTertiary) TextField("Search Hugging Face…", text: $query) .textFieldStyle(.plain) .font(ZyquoTheme.body) if searching { ProgressView().controlSize(.mini) } } .padding(.horizontal, ZyquoTheme.Spacing.xs) .padding(.vertical, 5) .frame(maxWidth: 280) .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s)) .overlay( RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s) .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline) ) Picker("", selection: $scope) { Text("Featured").tag(HubService.Scope.featured) Text("mlx-community").tag(HubService.Scope.mlxCommunity) Text("All MLX").tag(HubService.Scope.allMLX) } .pickerStyle(.segmented) .frame(width: 300) Spacer() Picker("Size", selection: $sizeFilter) { ForEach(SizeClass.allCases, id: \.self) { Text($0.rawValue).tag($0) } } .frame(width: 120) Picker("Sort", selection: $sort) { Text("Downloads").tag(HubService.Sort.downloads) Text("Likes").tag(HubService.Sort.likes) Text("Newest").tag(HubService.Sort.newest) } .frame(width: 130) .disabled(scope == .featured && query.isEmpty) } .padding(ZyquoTheme.Spacing.s) } // MARK: - Featured private var featuredList: some View { ScrollView { LazyVStack(alignment: .leading, spacing: ZyquoTheme.Spacing.l) { Text("Hand-picked models, live-verified for this Mac") .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textTertiary) .padding(.top, ZyquoTheme.Spacing.m) ForEach(featuredSections, id: \.title) { section in if !section.models.isEmpty { VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.s) { Text(section.title) .font(ZyquoTheme.bodyEmphasis) .foregroundStyle(ZyquoTheme.textPrimary) ForEach(section.models) { model in FeaturedModelCard(model: model) } } } } } .padding(.horizontal, ZyquoTheme.Spacing.l) .padding(.bottom, ZyquoTheme.Spacing.l) .frame(maxWidth: 860) .frame(maxWidth: .infinity) } } private var featuredSections: [(title: String, models: [CatalogModel])] { func matchesSize(_ m: CatalogModel) -> Bool { switch sizeFilter { case .any: true case .tiny: m.categories.contains(.tiny) case .mid: m.categories.contains(.mid) case .large: m.categories.contains(.large) } } let featured = ModelCatalog.featured.filter(matchesSize) return [ ("Starter picks for this Mac", ModelCatalog.starterPicks().filter(matchesSize)), ("General", featured.filter { $0.categories.contains(.mid) || $0.categories.contains(.large) } .filter { !$0.categories.contains(.coding) && !$0.categories.contains(.reasoning) }), ("Small & fast", featured.filter { $0.categories.contains(.tiny) }), ("Coding", featured.filter { $0.categories.contains(.coding) }), ("Reasoning", featured.filter { $0.categories.contains(.reasoning) }), ] } // MARK: - Live search private var searchResults: some View { ScrollView { LazyVStack(spacing: ZyquoTheme.Spacing.s) { if let error = searchError { Text(error) .font(ZyquoTheme.body) .foregroundStyle(ZyquoTheme.danger) .padding(.top, ZyquoTheme.Spacing.xl) } else if results.isEmpty && !searching { Text(query.isEmpty ? "Type to search the Hub" : "No MLX models found for “\(query)”") .font(ZyquoTheme.body) .foregroundStyle(ZyquoTheme.textTertiary) .padding(.top, ZyquoTheme.Spacing.xl) } ForEach(filteredResults) { summary in HubModelCard(summary: summary) } } .padding(ZyquoTheme.Spacing.l) .frame(maxWidth: 860) .frame(maxWidth: .infinity) } } private var filteredResults: [HubService.ModelSummary] { results.filter { summary in switch sizeFilter { case .any: return true case .tiny: return paramsBillions(summary.id).map { $0 <= 4.5 } ?? true case .mid: return paramsBillions(summary.id).map { $0 > 4.5 && $0 <= 20 } ?? true case .large: return paramsBillions(summary.id).map { $0 > 20 } ?? true } } } /// Parses "…-7B-…"/"…-0.6B-…" from a repo name. private func paramsBillions(_ repoID: String) -> Double? { let name = shortModelName(repoID) guard let regex = try? NSRegularExpression(pattern: #"(\d+(?:\.\d+)?)[Bb]"#) else { return nil } let range = NSRange(name.startIndex..., in: name) guard let match = regex.firstMatch(in: name, range: range), let r = Range(match.range(at: 1), in: name) else { return nil } return Double(name[r]) } private func debounceSearch() { searchTask?.cancel() guard !(scope == .featured && query.isEmpty) else { return } searchTask = Task { try? await Task.sleep(for: .milliseconds(350)) guard !Task.isCancelled else { return } await runSearch() } } private func runSearch() async { searching = true searchError = nil do { let hub = HubService(token: app.settings.hfToken.isEmpty ? nil : app.settings.hfToken) let effectiveScope: HubService.Scope = scope == .featured ? .mlxCommunity : scope results = try await hub.search(query: query, scope: effectiveScope, sort: sort) } catch is CancellationError { } catch { searchError = error.localizedDescription results = [] } searching = false } }