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%
11.2 KB · 262 lines swift
Raw Blame History
1//2//  ModelCards.swift3//  Zyquo Local4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import SwiftUI1011/// Shared download-state footer: Download button that flips into a live12/// progress card (bar, MB/s, ETA, pause/cancel) and into Installed state.13struct DownloadStateFooter: View {14    let repoID: String15    /// Known download size (from catalog); live search cards fetch on demand.16    var sizeHint: String?17    @Environment(AppModel.self) private var app1819    private var installed: Bool { app.store.model(for: repoID) != nil }20    private var task: DownloadTask? { app.downloads.task(for: repoID) }2122    var body: some View {23        if installed {24            HStack(spacing: ZyquoTheme.Spacing.xs) {25                Label("Installed", systemImage: "checkmark.circle.fill")26                    .font(ZyquoTheme.caption.weight(.medium))27                    .foregroundStyle(ZyquoTheme.success)28                Spacer()29                Button("Load") {30                    Task { await app.loadModel(repoID: repoID) }31                }32                .buttonStyle(.bordered)33                .controlSize(.small)34            }35        } else if let task, task.state == .downloading || task.state == .verifying || task.state == .queued {36            VStack(spacing: 4) {37                ProgressView(value: task.fractionCompleted)38                    .tint(ZyquoTheme.accent)39                    .animation(.linear(duration: 0.3), value: task.fractionCompleted)40                HStack(spacing: ZyquoTheme.Spacing.s) {41                    Text("\(formatBytes(task.receivedBytes)) / \(formatBytes(task.totalBytes))")42                    if let speed = app.downloads.speeds[repoID] {43                        Text(formatSpeed(speed))44                    }45                    if let eta = app.downloads.eta(for: repoID) {46                        Text(formatETA(eta))47                    }48                    Spacer()49                    Button {50                        app.downloads.pause(repoID: repoID)51                    } label: {52                        Image(systemName: "pause.fill")53                    }54                    .buttonStyle(.plain)55                    .help("Pause")56                    Button {57                        app.downloads.cancel(repoID: repoID)58                    } label: {59                        Image(systemName: "xmark")60                    }61                    .buttonStyle(.plain)62                    .help("Cancel and remove partial files")63                }64                .font(ZyquoTheme.caption.monospacedDigit())65                .foregroundStyle(ZyquoTheme.textTertiary)66            }67        } else if let task, task.state == .paused {68            HStack {69                Text("Paused at \(Int(task.fractionCompleted * 100))%")70                    .font(ZyquoTheme.caption)71                    .foregroundStyle(ZyquoTheme.textSecondary)72                Spacer()73                Button("Resume") { app.downloads.resume(repoID: repoID) }74                    .buttonStyle(.borderedProminent)75                    .tint(ZyquoTheme.accent)76                    .controlSize(.small)77                Button("Cancel") { app.downloads.cancel(repoID: repoID) }78                    .buttonStyle(.bordered)79                    .controlSize(.small)80            }81        } else if let task, task.state == .failed {82            HStack {83                Text(task.errorDescription ?? "Download failed")84                    .font(ZyquoTheme.caption)85                    .foregroundStyle(ZyquoTheme.danger)86                    .lineLimit(2)87                Spacer()88                Button("Retry") { app.downloads.resume(repoID: repoID) }89                    .buttonStyle(.bordered)90                    .controlSize(.small)91            }92        } else {93            HStack {94                if let sizeHint {95                    Text(sizeHint)96                        .font(ZyquoTheme.caption)97                        .foregroundStyle(ZyquoTheme.textTertiary)98                }99                Spacer()100                Button {101                    Task { await app.downloads.download(repoID: repoID) }102                } label: {103                    Label("Download", systemImage: "arrow.down.circle")104                        .font(ZyquoTheme.bodyEmphasis)105                }106                .buttonStyle(.borderedProminent)107                .tint(ZyquoTheme.accent)108                .controlSize(.small)109            }110        }111    }112}113114/// One curated catalog entry, editorial style.115struct FeaturedModelCard: View {116    let model: CatalogModel117    @Environment(AppModel.self) private var app118119    var body: some View {120        VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xs) {121            HStack(spacing: ZyquoTheme.Spacing.xs) {122                Text(shortModelName(model.repoID))123                    .font(ZyquoTheme.bodyEmphasis)124                    .foregroundStyle(ZyquoTheme.textPrimary)125                InfoBadge(text: model.params)126                InfoBadge(text: model.quant)127                Spacer()128                VerdictBadge(verdict: model.verdict)129            }130            Text(model.blurb)131                .font(ZyquoTheme.body)132                .foregroundStyle(ZyquoTheme.textSecondary)133            DownloadStateFooter(134                repoID: model.repoID,135                sizeHint: String(format: "%.1f GB download · needs %d GB+ Mac", model.sizeGB, model.minRAMGB)136            )137        }138        .padding(ZyquoTheme.Spacing.m)139        .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m))140        .overlay(141            RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m)142                .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline)143        )144    }145}146147/// One live Hub search result.148struct HubModelCard: View {149    let summary: HubService.ModelSummary150    @Environment(AppModel.self) private var app151    @State private var totalBytes: Int64?152153    var body: some View {154        VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xs) {155            HStack(spacing: ZyquoTheme.Spacing.xs) {156                VStack(alignment: .leading, spacing: 1) {157                    Text(shortModelName(summary.id))158                        .font(ZyquoTheme.bodyEmphasis)159                        .foregroundStyle(ZyquoTheme.textPrimary)160                        .lineLimit(1)161                    Text(summary.id.split(separator: "/").first.map(String.init) ?? "")162                        .font(ZyquoTheme.caption)163                        .foregroundStyle(ZyquoTheme.textTertiary)164                }165                Spacer()166                if let downloads = summary.downloads {167                    Label("\(downloads.formatted(.number.notation(.compactName)))", systemImage: "arrow.down.circle")168                        .font(ZyquoTheme.caption)169                        .foregroundStyle(ZyquoTheme.textTertiary)170                }171                if summary.isGated {172                    Label("Gated", systemImage: "lock")173                        .font(ZyquoTheme.caption)174                        .foregroundStyle(ZyquoTheme.warning)175                        .help("Requires an approved Hugging Face token (Settings ▸ Models & Storage)")176                }177                if !summary.isSupportedArchitecture {178                    Label("Unsupported", systemImage: "exclamationmark.triangle")179                        .font(ZyquoTheme.caption)180                        .foregroundStyle(ZyquoTheme.danger)181                        .help("Architecture “\(summary.architecture ?? "?")” is not supported by the MLX engine")182                }183                if let totalBytes {184                    VerdictBadge(verdict: MemoryAdvisor.verdict(weightsBytes: totalBytes))185                }186            }187            DownloadStateFooter(repoID: summary.id, sizeHint: totalBytes.map(formatBytes))188        }189        .padding(ZyquoTheme.Spacing.m)190        .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m))191        .overlay(192            RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m)193                .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline)194        )195        .task(id: summary.id) {196            // Fetch true download size lazily per card.197            guard totalBytes == nil else { return }198            let hub = HubService(token: app.settings.hfToken.isEmpty ? nil : app.settings.hfToken)199            totalBytes = try? await hub.requiredFiles(of: summary.id).totalBytes200        }201    }202}203204/// Bottom drawer listing all active/queued/paused downloads.205struct DownloadsDrawer: View {206    @Environment(AppModel.self) private var app207208    var body: some View {209        VStack(spacing: 0) {210            Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline)211            ScrollView(.horizontal, showsIndicators: false) {212                HStack(spacing: ZyquoTheme.Spacing.s) {213                    ForEach(app.downloads.tasks.filter { $0.state != .completed && $0.state != .cancelled }) { task in214                        HStack(spacing: ZyquoTheme.Spacing.xs) {215                            VStack(alignment: .leading, spacing: 2) {216                                Text(shortModelName(task.repoID))217                                    .font(ZyquoTheme.caption.weight(.medium))218                                    .lineLimit(1)219                                ProgressView(value: task.fractionCompleted)220                                    .tint(ZyquoTheme.accent)221                                    .frame(width: 140)222                            }223                            switch task.state {224                            case .downloading, .queued, .verifying:225                                Button {226                                    app.downloads.pause(repoID: task.repoID)227                                } label: {228                                    Image(systemName: "pause.fill").font(.system(size: 10))229                                }230                                .buttonStyle(.plain)231                            case .paused, .failed:232                                Button {233                                    app.downloads.resume(repoID: task.repoID)234                                } label: {235                                    Image(systemName: "play.fill").font(.system(size: 10))236                                }237                                .buttonStyle(.plain)238                            default:239                                EmptyView()240                            }241                            Button {242                                app.downloads.cancel(repoID: task.repoID)243                            } label: {244                                Image(systemName: "xmark").font(.system(size: 10))245                            }246                            .buttonStyle(.plain)247                        }248                        .padding(ZyquoTheme.Spacing.xs)249                        .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s))250                        .overlay(251                            RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s)252                                .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline)253                        )254                    }255                }256                .padding(ZyquoTheme.Spacing.s)257            }258        }259        .background(ZyquoTheme.background)260    }261}262