SPB Git

spb/zyquo-mlx Public MIT

The local MLX foundry for your Mac — run, fine-tune, quantize, and ship models. Nothing leaves your machine.

Swift 93.4% Python 3.8% Makefile 2.2% Shell 0.5%
8.2 KB · 205 lines swift
Raw Blame History
1//2//  DiscoverView.swift3//  Zyquo MLX4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import SwiftUI1011/// Models › Discover: Featured catalog + live mlx-community search with12/// resumable downloads (charter Phase 6).13struct DiscoverView: View {14    @Bindable var model: AppModel15    @State private var query = ""16    @State private var results: [HubModel] = []17    @State private var isSearching = false18    @State private var searchError: String?19    @State private var downloads: [String: DownloadProgress] = [:]20    @State private var downloadErrors: [String: String] = [:]2122    private var installedIDs: Set<String> {23        Set(model.models.compactMap(\.repoID))24    }2526    var body: some View {27        VStack(spacing: 0) {28            searchField29                .padding(.horizontal, ZyquoTheme.spacing20)30                .padding(.bottom, ZyquoTheme.spacing12)3132            ScrollView {33                LazyVStack(alignment: .leading, spacing: ZyquoTheme.spacing8) {34                    if let searchError {35                        Text(searchError)36                            .font(ZyquoTheme.captionFont)37                            .foregroundStyle(ZyquoTheme.danger)38                    }39                    if query.isEmpty {40                        Text("Featured — live-verified for this Mac")41                            .font(ZyquoTheme.headlineFont)42                            .foregroundStyle(ZyquoTheme.textPrimary)43                            .padding(.bottom, ZyquoTheme.spacing4)44                        ForEach(Catalog.featured) { entry in45                            DiscoverRow(46                                repoID: entry.id,47                                title: entry.name,48                                subtitle: "\(entry.params) · \(entry.quant) · \(ByteCountFormatter.string(fromByteCount: entry.weightBytes, countStyle: .file))\(entry.blurb)",49                                type: entry.type,50                                verdict: entry.verdict,51                                installed: installedIDs.contains(entry.id),52                                progress: downloads[entry.id],53                                error: downloadErrors[entry.id],54                                download: { download(repo: entry.id) },55                                cancel: { Task { await DownloadManager.shared.cancel(repo: entry.id) } })56                        }57                    } else if isSearching {58                        HStack {59                            ProgressView().controlSize(.small)60                            Text("Searching mlx models…")61                                .font(ZyquoTheme.captionFont)62                                .foregroundStyle(ZyquoTheme.textSecondary)63                        }64                        .frame(maxWidth: .infinity)65                        .padding(.top, ZyquoTheme.spacing32)66                    } else {67                        ForEach(results) { result in68                            DiscoverRow(69                                repoID: result.id,70                                title: result.id,71                                subtitle: "\(result.downloads.formatted()) downloads · \(result.likes) likes"72                                    + (result.gated ? " · gated" : ""),73                                type: result.modelType,74                                verdict: nil,75                                installed: installedIDs.contains(result.id),76                                progress: downloads[result.id],77                                error: downloadErrors[result.id],78                                download: { download(repo: result.id) },79                                cancel: { Task { await DownloadManager.shared.cancel(repo: result.id) } })80                        }81                        if results.isEmpty {82                            Text("No MLX models match “\(query)”.")83                                .font(ZyquoTheme.bodyFont)84                                .foregroundStyle(ZyquoTheme.textSecondary)85                                .frame(maxWidth: .infinity)86                                .padding(.top, ZyquoTheme.spacing32)87                        }88                    }89                }90                .padding(.horizontal, ZyquoTheme.spacing20)91                .padding(.bottom, ZyquoTheme.spacing20)92            }93        }94        .task(id: query) {95            guard !query.isEmpty else { return }96            isSearching = true97            searchError = nil98            try? await Task.sleep(for: .milliseconds(350)) // debounce99            guard !Task.isCancelled else { return }100            do {101                results = try await HubService.shared.search(query: query)102            } catch {103                searchError = error.localizedDescription104            }105            isSearching = false106        }107    }108109    private var searchField: some View {110        HStack(spacing: ZyquoTheme.spacing8) {111            Image(systemName: "magnifyingglass")112                .foregroundStyle(ZyquoTheme.textTertiary)113            TextField("Search mlx-community models…", text: $query)114                .textFieldStyle(.plain)115                .font(ZyquoTheme.bodyFont)116        }117        .padding(.horizontal, ZyquoTheme.spacing12)118        .padding(.vertical, ZyquoTheme.spacing8)119        .background(ZyquoTheme.surfaceSecondary)120        .clipShape(RoundedRectangle(cornerRadius: ZyquoTheme.radiusSmall))121    }122123    private func download(repo: String) {124        downloadErrors[repo] = nil125        Task {126            let events = await DownloadManager.shared.download(repo: repo)127            for await event in events {128                switch event {129                case .progress(let progress):130                    downloads[repo] = progress131                case .finished:132                    downloads[repo] = nil133                    await model.refresh()134                case .failed(let message):135                    downloads[repo] = nil136                    downloadErrors[repo] = message137                }138            }139        }140    }141}142143private struct DiscoverRow: View {144    let repoID: String145    let title: String146    let subtitle: String147    let type: ModelType148    let verdict: MemoryVerdict?149    let installed: Bool150    let progress: DownloadProgress?151    let error: String?152    let download: () -> Void153    let cancel: () -> Void154155    var body: some View {156        HStack(spacing: ZyquoTheme.spacing12) {157            VStack(alignment: .leading, spacing: ZyquoTheme.spacing2) {158                Text(title)159                    .font(ZyquoTheme.bodyFont.weight(.medium))160                    .foregroundStyle(ZyquoTheme.textPrimary)161                    .lineLimit(1)162                Text(error ?? subtitle)163                    .font(ZyquoTheme.captionFont)164                    .foregroundStyle(error != nil ? ZyquoTheme.danger : ZyquoTheme.textSecondary)165                    .lineLimit(2)166            }167168            Spacer()169170            TypeBadge(type: type)171            if let verdict {172                VerdictBadge(verdict: verdict)173            }174175            if installed {176                StatusPill(text: "Installed", color: ZyquoTheme.success)177            } else if let progress {178                HStack(spacing: ZyquoTheme.spacing8) {179                    ProgressView(value: progress.fraction)180                        .frame(width: 90)181                    Text("\(Int(progress.fraction * 100))%")182                        .font(ZyquoTheme.monoSmallFont)183                        .foregroundStyle(ZyquoTheme.textSecondary)184                    Button(action: cancel) {185                        Image(systemName: "pause.circle")186                            .foregroundStyle(ZyquoTheme.textSecondary)187                    }188                    .buttonStyle(.plain)189                    .help("Pause (resumes from the same byte)")190                }191            } else {192                Button(action: download) {193                    Image(systemName: "arrow.down.circle.fill")194                        .font(.system(size: 20))195                        .foregroundStyle(ZyquoTheme.accent)196                }197                .buttonStyle(.plain)198                .help("Download")199            }200        }201        .padding(ZyquoTheme.spacing12)202        .zyquoCard()203    }204}205