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%
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